<?php

use App\Services\Stripe\FakeStripeClient;
use App\Services\Stripe\HttpStripeClient;
use Illuminate\Support\Facades\Http;

/**
 * A key says "I have already sent this call" — not "this is what the object is".
 *
 * The difference cost a day. A run created a Price at Stripe and died before the
 * row was written; the next sweep sent the SAME key with an added metadata field
 * and Stripe refused it for twenty-four hours — the sweep and, because
 * AddonPrices::ensure() also runs inside a customer's module booking, the
 * booking with it.
 *
 * So every parameter that goes on the wire goes into the key. On the catalogue
 * calls only: for a refund or a second subscription item, Stripe's refusal is
 * the thing standing between a customer and being charged twice.
 */
beforeEach(function () {
    // A stored vault row, not config(): `stripe.secret` is a STRICT vault entry
    // as of the operating-mode work, so it never falls back to the environment
    // in either direction — otherwise the .env would be the back door through
    // which a live key reaches a test-mode checkout. Without a real row
    // HttpStripeClient::secret() throws StripeNotConfigured, which is the point.
    withStripeSecret();
});

it('sends a different key once the metadata changes', function () {
    Http::fake(['api.stripe.com/*' => Http::response(['id' => 'price_x'])]);

    $client = new HttpStripeClient;
    $spoken = 'clupilot-addon-price-priority_support-month-3480-EUR';

    // The call as it stood before 9da1358, and the call after it: same money,
    // same interval, one metadata field more.
    $client->createPrice('prod_1', 3480, 'EUR', 'month',
        ['addon' => 'priority_support'], $spoken);

    $client->createPrice('prod_1', 3480, 'EUR', 'month',
        ['addon' => 'priority_support', 'tax_treatment' => 'domestic'], $spoken);

    $sent = collect(Http::recorded())
        ->map(fn (array $pair) => $pair[0]->header('Idempotency-Key')[0] ?? null)
        ->all();

    expect($sent[0])->toStartWith($spoken)
        ->and($sent[1])->toStartWith($spoken)
        ->and($sent[1])->not->toBe($sent[0]);
});

it('sends the same key for the very same call', function () {
    Http::fake(['api.stripe.com/*' => Http::response(['id' => 'price_x'])]);

    $client = new HttpStripeClient;

    foreach ([1, 2] as $ignored) {
        $client->createPrice('prod_1', 3480, 'EUR', 'month',
            ['addon' => 'priority_support'], 'clupilot-addon-price');
    }

    $sent = collect(Http::recorded())
        ->map(fn (array $pair) => $pair[0]->header('Idempotency-Key')[0] ?? null)
        ->unique()
        ->all();

    expect($sent)->toHaveCount(1);
});

it('fingerprints the product call too, where the same trap was waiting', function () {
    Http::fake(['api.stripe.com/*' => Http::response(['id' => 'prod_x'])]);

    $client = new HttpStripeClient;

    $client->createProduct('Priority Support', ['addon' => 'priority_support'], 'clupilot-addon-product-x');
    $client->createProduct('Priority Support', ['addon' => 'priority_support', 'sold_as' => 'entitlement'], 'clupilot-addon-product-x');

    $sent = collect(Http::recorded())
        ->map(fn (array $pair) => $pair[0]->header('Idempotency-Key')[0] ?? null)
        ->all();

    expect($sent[1])->not->toBe($sent[0]);
});

it('leaves the money calls their bare key, so Stripe still refuses a changed one', function () {
    Http::fake(['api.stripe.com/*' => Http::response(['id' => 'x'])]);

    $client = new HttpStripeClient;

    $client->refund('pi_1', 500, 'clupilot-refund-7');
    $client->cancelSubscription('sub_1', 'at_period_end', 'clupilot-cancel-7');
    $client->addSubscriptionItem('sub_1', 'price_1', 1, 'none', 'clupilot-item-7');

    $sent = collect(Http::recorded())
        ->map(fn (array $pair) => $pair[0]->header('Idempotency-Key')[0] ?? null)
        ->all();

    expect($sent)->toBe(['clupilot-refund-7', 'clupilot-cancel-7', 'clupilot-item-7']);
});

it('reproduces the refusal Stripe makes, which the fake used to swallow', function () {
    $fake = new FakeStripeClient;

    $fake->refund('pi_1', 500, 'clupilot-refund-7');

    // Same key, different amount. Stripe answers 400; the fake said nothing and
    // replayed the first refund's id, which is how a test could pass over the
    // very failure that stopped production.
    expect(fn () => $fake->refund('pi_1', 900, 'clupilot-refund-7'))
        ->toThrow(RuntimeException::class, 'same parameters');
});

it('mints a second price rather than blocking when the metadata moved', function () {
    $fake = new FakeStripeClient;

    $first = $fake->createPrice('prod_1', 3480, 'EUR', 'month',
        ['addon' => 'priority_support'], 'clupilot-addon-price');

    $second = $fake->createPrice('prod_1', 3480, 'EUR', 'month',
        ['addon' => 'priority_support', 'tax_treatment' => 'domestic'], 'clupilot-addon-price');

    // Two objects, no exception. That the second one is not WANTED is the job of
    // AdoptStripePrice, not of the key — see StripePriceAdoptionTest.
    expect($second)->not->toBe($first);
});

it('pages through every active price of a product', function () {
    Http::fake([
        'api.stripe.com/*' => Http::sequence()
            ->push([
                'data' => [
                    ['id' => 'price_a', 'unit_amount' => 3480, 'currency' => 'eur',
                        'created' => 100, 'recurring' => ['interval' => 'month'],
                        'metadata' => ['addon' => 'priority_support']],
                    ['id' => 'price_b', 'unit_amount' => 41760, 'currency' => 'eur',
                        'created' => 101, 'recurring' => ['interval' => 'year'], 'metadata' => []],
                ],
                'has_more' => true,
            ])
            ->push([
                'data' => [
                    ['id' => 'price_c', 'unit_amount' => 2900, 'currency' => 'eur',
                        'created' => 102, 'recurring' => ['interval' => 'month'],
                        'metadata' => ['addon' => 'priority_support', 'tax_treatment' => 'reverse_charge']],
                ],
                'has_more' => false,
            ]),
    ]);

    $prices = (new HttpStripeClient)->activePricesFor('prod_1');

    expect($prices)->toHaveCount(3)
        ->and($prices[0])->toBe([
            'id' => 'price_a',
            'unit_amount' => 3480,
            // Upper case, because that is how our own tables hold it and the
            // comparison in AdoptStripePrice must not have to remember which
            // side is which.
            'currency' => 'EUR',
            'interval' => 'month',
            // Stripe's own defaults, absent from every price in this response —
            // reported here rather than filtered, which is the whole point of
            // this test's neighbour below.
            'interval_count' => 1,
            'usage_type' => 'licensed',
            'transform_quantity' => false,
            'billing_scheme' => 'per_unit',
            'created' => 100,
            'metadata' => ['addon' => 'priority_support'],
        ])
        ->and($prices[2]['id'])->toBe('price_c');

    // The second page has to be asked for, or this reintroduces the very gap it
    // exists to close — a family product accumulates prices across versions,
    // terms, treatments and every rate change.
    Http::assertSent(fn ($request) => str_contains($request->url(), 'starting_after=price_b'));

    // Archived prices are none of our business here: we are looking for
    // something to SELL on.
    Http::assertSent(fn ($request) => str_contains($request->url(), 'active=true'));
});

it('reports the properties that decide what a price charges, and filters none of them', function () {
    Http::fake([
        'api.stripe.com/*' => Http::response([
            'data' => [
                ['id' => 'price_ordinary', 'unit_amount' => 3480, 'currency' => 'eur',
                    'created' => 100, 'recurring' => ['interval' => 'month'], 'metadata' => []],
                ['id' => 'price_quarterly', 'unit_amount' => 3480, 'currency' => 'eur',
                    'created' => 101, 'recurring' => ['interval' => 'month', 'interval_count' => 3],
                    'metadata' => []],
                ['id' => 'price_divided', 'unit_amount' => 3480, 'currency' => 'eur',
                    'created' => 102, 'recurring' => ['interval' => 'month'],
                    'transform_quantity' => ['divide_by' => 10, 'round' => 'up'], 'metadata' => []],
                ['id' => 'price_tiered', 'unit_amount' => null, 'currency' => 'eur',
                    'created' => 103, 'recurring' => ['interval' => 'month', 'usage_type' => 'metered'],
                    'billing_scheme' => 'tiered', 'metadata' => []],
            ],
            'has_more' => false,
        ]),
    ]);

    $prices = collect((new HttpStripeClient)->activePricesFor('prod_1'))->keyBy('id');

    // All four come back. Deciding which are usable is AdoptStripePrice's job —
    // it is the class that promises adoption cannot move money, and it could not
    // keep that promise while the fields lived only here.
    expect($prices)->toHaveCount(4)
        ->and($prices['price_ordinary'])->toMatchArray([
            'interval_count' => 1, 'usage_type' => 'licensed',
            'transform_quantity' => false, 'billing_scheme' => 'per_unit',
        ])
        ->and($prices['price_quarterly']['interval_count'])->toBe(3)
        ->and($prices['price_divided']['transform_quantity'])->toBeTrue()
        ->and($prices['price_tiered']['usage_type'])->toBe('metered')
        ->and($prices['price_tiered']['billing_scheme'])->toBe('tiered');
});

it('writes metadata onto a price that already exists', function () {
    Http::fake(['api.stripe.com/*' => Http::response(['id' => 'price_a'])]);

    (new HttpStripeClient)->updatePriceMetadata('price_a', ['addon' => 'priority_support']);

    Http::assertSent(fn ($request) => $request->url() === 'https://api.stripe.com/v1/prices/price_a'
        && $request['metadata[addon]'] === 'priority_support');
});

it('lets the fake answer with the prices it holds, minus the archived ones', function () {
    $fake = new FakeStripeClient;

    $kept = $fake->createPrice('prod_1', 3480, 'EUR', 'month', ['addon' => 'priority_support']);
    $gone = $fake->createPrice('prod_1', 2900, 'EUR', 'month', ['addon' => 'priority_support']);
    $other = $fake->createPrice('prod_2', 3480, 'EUR', 'month', []);
    $fake->archivePrice($gone);

    $fake->plantPrice('price_orphan', 'prod_1', 3480, 'EUR', 'month',
        ['addon' => 'priority_support'], created: 0);

    $found = collect($fake->activePricesFor('prod_1'))->pluck('id')->all();

    expect($found)->toContain($kept, 'price_orphan')
        ->and($found)->not->toContain($gone, $other);
});

it('merges metadata onto a price the way Stripe does, rather than replacing it', function () {
    $fake = new FakeStripeClient;
    $fake->plantPrice('price_a', 'prod_1', 3480, 'EUR', 'month',
        ['addon' => 'priority_support', 'internal_note' => 'kept']);

    $fake->updatePriceMetadata('price_a', ['tax_treatment' => 'domestic']);

    // Stripe merges: a key you do not send stays. A fake that replaced would let
    // a test prove the opposite of production — and it is exactly this merging
    // that AdoptStripePrice's "write only when it differs" comparison is built
    // around.
    expect($fake->activePricesFor('prod_1')[0]['metadata'])->toBe([
        'addon' => 'priority_support',
        'internal_note' => 'kept',
        'tax_treatment' => 'domestic',
    ]);
});

it('hands metadata back as strings, the way the wire does', function () {
    $fake = new FakeStripeClient;
    $fake->plantPrice('price_a', 'prod_1', 3480, 'EUR', 'month', ['plan_price_id' => 42]);

    // HttpStripeClient casts; the fake did not. An un-cast int is what makes
    // confirms()'s strict === fail forever for that price.
    expect($fake->activePricesFor('prod_1')[0]['metadata']['plan_price_id'])->toBe('42');
});

it('refuses to stop half-read when Stripe says there is another page', function () {
    Http::fake([
        'api.stripe.com/*' => Http::response([
            // has_more, and the last item carries no id to page after. Stopping
            // here leaves an orphan unseen — which is a second live Price for one
            // figure, the incident this whole feature exists to end.
            'data' => [['unit_amount' => 3480, 'currency' => 'eur', 'recurring' => ['interval' => 'month']]],
            'has_more' => true,
        ]),
    ]);

    expect(fn () => (new HttpStripeClient)->activePricesFor('prod_1'))
        ->toThrow(RuntimeException::class, 'half-read');
});

it('pages through every active product of the account', function () {
    Http::fake([
        'api.stripe.com/*' => Http::sequence()
            ->push([
                'data' => [
                    ['id' => 'prod_a', 'name' => 'Team', 'created' => 100,
                        'metadata' => ['plan_family' => 'team', 'plan_family_id' => '3']],
                    ['id' => 'prod_b', 'name' => 'Priority Support', 'created' => 101,
                        'metadata' => ['addon' => 'priority_support']],
                ],
                'has_more' => true,
            ])
            ->push([
                'data' => [['id' => 'prod_c', 'name' => 'Etwas anderes', 'created' => 102, 'metadata' => []]],
                'has_more' => false,
            ]),
    ]);

    $products = (new HttpStripeClient)->activeProducts();

    expect($products)->toHaveCount(3)
        ->and($products[0])->toBe([
            'id' => 'prod_a',
            'name' => 'Team',
            'created' => 100,
            'metadata' => ['plan_family' => 'team', 'plan_family_id' => '3'],
        ])
        ->and($products[2]['id'])->toBe('prod_c');

    // No metadata filter is sent: Stripe cannot search by it, so the whole
    // active list comes back and the matching happens here. An account holds a
    // handful of products, not thousands.
    Http::assertSent(fn ($request) => str_contains($request->url(), 'active=true')
        && ! str_contains($request->url(), 'metadata'));
    Http::assertSent(fn ($request) => str_contains($request->url(), 'starting_after=prod_b'));
});

it('lets the fake answer with the products it holds', function () {
    $fake = new FakeStripeClient;

    $minted = $fake->createProduct('Team', ['plan_family_id' => '3']);
    $fake->plantProduct('prod_orphan', 'Team', ['plan_family_id' => '3'], created: 0);

    expect(collect($fake->activeProducts())->pluck('id')->all())
        ->toContain($minted, 'prod_orphan');
});
