<?php

use App\Actions\ApplyPlanChange;
use App\Actions\GrantSubscription;
use App\Actions\MoveStripeSubscriptionPrice;
use App\Models\Customer;
use App\Models\Order;
use App\Models\PlanPrice;
use App\Models\Subscription;
use App\Models\SubscriptionRecord;
use App\Services\Stripe\FakeStripeClient;
use App\Services\Stripe\StripeClient;
use Illuminate\Contracts\Console\Kernel;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Queue;

/**
 * A plan change that reaches Stripe.
 *
 * The contract moved, the machine was resized, the register was written — and
 * Stripe went on billing the old price, because nothing ever told it. A customer
 * who upgraded in March paid for the smaller package every month afterwards.
 *
 * Two things were missing besides the call itself: the SUBSCRIPTION ITEM id,
 * which is what a price hangs off and which nothing stored, and a decision about
 * proration per direction.
 */
function stripeCatalogue(): FakeStripeClient
{
    $fake = new FakeStripeClient;
    app()->instance(StripeClient::class, $fake);

    // The plan prices need their Stripe ids before anything can be moved onto
    // one — that is what stripe:sync-catalogue is for.
    app(Kernel::class)->call('stripe:sync-catalogue');

    return $fake;
}

/** A paying contract Stripe knows about, with the item it bills through. */
function stripeContractOn(string $plan, ?string $itemId = 'si_pc'): Subscription
{
    $order = Order::factory()->withSubscription()->create(['datacenter' => 'fsn', 'plan' => $plan]);

    $order->subscription->update([
        'stripe_subscription_id' => 'sub_pc',
        'stripe_item_id' => $itemId,
    ]);

    return $order->subscription->fresh();
}

/** The Stripe Price a package is sold at on this term. */
function stripePriceFor(string $plan, string $term = 'monthly'): string
{
    return (string) PlanPrice::query()
        ->where('plan_version_id', Subscription::snapshotFrom($plan, $term)['plan_version_id'])
        ->where('term', $term)
        ->value('stripe_price_id');
}

it('moves an upgrade onto the new price and has Stripe settle it at once', function () {
    fakeServices();
    Queue::fake();

    $stripe = stripeCatalogue();
    $contract = stripeContractOn('team');

    app(ApplyPlanChange::class)($contract, 'business');

    expect($stripe->priceChanges)->toHaveCount(1);

    // always_invoice, not a proration riding on the next cycle invoice: the
    // cycle invoice is the one WE issue a document for, from the contract's
    // frozen price, and it has to be exactly what Stripe took.
    expect($stripe->priceChanges[0])->toBe([
        'subscription' => 'sub_pc',
        'item' => 'si_pc',
        'price' => stripePriceFor('business'),
        'proration' => StripeClient::PRORATE_IMMEDIATELY,
    ]);

    $fresh = $contract->fresh();

    expect($fresh->stripe_price_id)->toBe(stripePriceFor('business'))
        ->and($fresh->stripe_price_synced_at)->not->toBeNull()
        ->and($fresh->stripe_price_sync)->toBeNull();
});

it('moves a downgrade onto the smaller price and has Stripe settle nothing', function () {
    fakeServices();
    Queue::fake();

    $stripe = stripeCatalogue();
    $contract = stripeContractOn('business');

    // A downgrade lands exactly at the period end, which is the only moment
    // PlanChange allows one.
    Carbon::setTestNow($contract->current_period_end->copy()->addHour());

    app(ApplyPlanChange::class)($contract, 'team');

    // Nothing to prorate at a boundary, and anything but `none` risks a stray
    // credit line for a fraction of a day. The next cycle simply bills less.
    expect($stripe->priceChanges)->toHaveCount(1)
        ->and($stripe->priceChanges[0]['price'])->toBe(stripePriceFor('team'))
        ->and($stripe->priceChanges[0]['proration'])->toBe(StripeClient::PRORATE_NONE);

    Carbon::setTestNow();
});

it('leaves a granted contract alone and does not call Stripe at all', function () {
    fakeServices();
    Queue::fake();

    $stripe = stripeCatalogue();

    // Any call at all would throw and be recorded as a failure, so a clean
    // contract afterwards is proof that none was made.
    $stripe->failWith = 'Stripe must not be called for a granted contract.';

    $customer = Customer::factory()->create();
    $granted = app(GrantSubscription::class)($customer, admin(), 'team', 'monthly', 'fsn', 0);

    app(ApplyPlanChange::class)($granted->fresh(), 'business');

    $fresh = $granted->fresh();

    // The change lands exactly as it does for anyone else…
    expect($fresh->plan)->toBe('business')
        ->and(SubscriptionRecord::query()->where('event', SubscriptionRecord::EVENT_UPGRADE)->count())->toBe(1)
        // …and nothing was asked of Stripe, nor recorded as owing it.
        ->and($stripe->priceChanges)->toBeEmpty()
        ->and($fresh->stripe_price_sync)->toBeNull()
        ->and($fresh->stripe_price_id)->toBeNull();
});

it('does not undo a change Stripe could not be told about, and says where it stopped', function () {
    fakeServices();
    Queue::fake();

    $stripe = stripeCatalogue();
    $contract = stripeContractOn('team');

    $stripe->failWith = 'Connection timed out';

    app(ApplyPlanChange::class)($contract, 'business');

    $fresh = $contract->fresh();

    // The contract and the machine have already moved. Rolling that back
    // because an API was away would leave the customer on a package their
    // machine no longer matches.
    expect($fresh->plan)->toBe('business')
        ->and(SubscriptionRecord::query()->where('event', SubscriptionRecord::EVENT_UPGRADE)->count())->toBe(1)
        ->and($fresh->stripe_price_id)->toBeNull();

    // And what did not happen is on the contract, not only in a log line that
    // scrolls away: this customer is still being charged the old price.
    expect($fresh->stripe_price_sync['error'])->toContain('Connection timed out')
        ->and($fresh->stripe_price_sync['price'])->toBe(stripePriceFor('business'))
        ->and($fresh->stripe_price_sync['behaviour'])->toBe(StripeClient::PRORATE_IMMEDIATELY)
        ->and($fresh->stripe_price_sync['attempts'])->toBe(1);

    // The hourly sweep finishes it once Stripe answers again, with the
    // behaviour the direction chose at the time.
    $stripe->failWith = null;
    $this->artisan('clupilot:sync-stripe-subscriptions')->assertSuccessful();

    expect($stripe->priceChanges)->toHaveCount(1)
        ->and($stripe->priceChanges[0]['proration'])->toBe(StripeClient::PRORATE_IMMEDIATELY)
        ->and($contract->fresh()->stripe_price_sync)->toBeNull()
        ->and($contract->fresh()->stripe_price_id)->toBe(stripePriceFor('business'));
});

it('parks the change when the catalogue has never been mirrored into Stripe', function () {
    fakeServices();
    Queue::fake();

    // No stripe:sync-catalogue: the prices carry no Stripe id, so there is
    // nothing to move the subscription onto.
    $stripe = new FakeStripeClient;
    app()->instance(StripeClient::class, $stripe);

    $contract = stripeContractOn('team');

    app(ApplyPlanChange::class)($contract, 'business');

    expect($stripe->priceChanges)->toBeEmpty()
        ->and($contract->fresh()->plan)->toBe('business')
        ->and($contract->fresh()->stripe_price_sync['error'])->toContain('stripe:sync-catalogue');
});

it('refuses to move a Stripe-billed contract onto an internal package instead of parking it forever', function () {
    fakeServices();
    Queue::fake();

    // A full, successful sync — unlike the test above. Enterprise is
    // `internal` (see plan_families.internal) and stripe:sync-catalogue
    // skips every internal family on purpose (its own docblock: a Price
    // cannot be deleted once minted, so a package nobody is ever charged
    // for stays out of the mirror entirely). So even after a clean run its
    // price carries no Stripe id — and no later run will ever add one while
    // it stays internal, which is what tells this apart from the ordinary
    // "not synced YET" case above that one sync run fixes.
    $stripe = stripeCatalogue();
    $contract = stripeContractOn('team');

    app(ApplyPlanChange::class)($contract, 'enterprise');

    // Refused before anything moved: the contract, the register and Stripe
    // all stay exactly where they were, and nothing sits in
    // stripe_price_sync for clupilot:sync-stripe-subscriptions to retry
    // hourly, forever, because the price it is retrying for will never
    // exist.
    expect($stripe->priceChanges)->toBeEmpty()
        ->and($contract->fresh()->plan)->toBe('team')
        ->and($contract->fresh()->stripe_price_sync)->toBeNull()
        ->and(SubscriptionRecord::query()->where('event', SubscriptionRecord::EVENT_UPGRADE)->count())->toBe(0);
});

it('learns the subscription item from Stripe’s own events', function () {
    $contract = stripeContractOn('team', itemId: null);

    $this->postJson(route('webhooks.stripe'), [
        'id' => 'evt_items', 'type' => 'customer.subscription.updated',
        'data' => ['object' => [
            'id' => 'sub_pc', 'status' => 'active',
            'items' => ['data' => [['id' => 'si_learned', 'price' => ['id' => 'price_x']]]],
            'current_period_start' => now()->timestamp,
            'current_period_end' => now()->addMonth()->timestamp,
        ]],
    ])->assertOk();

    // Nothing stored an item id before, and a price hangs off the item — so
    // without this a plan change had no handle to swap anything with.
    expect($contract->fresh()->stripe_item_id)->toBe('si_learned');
});

it('asks Stripe for the item a contract older than the column never recorded', function () {
    fakeServices();
    Queue::fake();

    $stripe = stripeCatalogue();
    $stripe->items['sub_pc'] = 'si_fetched';

    $contract = stripeContractOn('team', itemId: null);

    app(ApplyPlanChange::class)($contract, 'business');

    // Asked once and kept: the item id survives a price swap, so no later
    // change has to ask again.
    expect($stripe->priceChanges[0]['item'])->toBe('si_fetched')
        ->and($contract->fresh()->stripe_item_id)->toBe('si_fetched');
});

it('does not ask Stripe to move a subscription that is already on the price', function () {
    fakeServices();
    Queue::fake();

    $stripe = stripeCatalogue();
    $contract = stripeContractOn('team');

    app(ApplyPlanChange::class)($contract, 'business');
    expect($stripe->priceChanges)->toHaveCount(1);

    // Straight back through, as a retried trigger or the sweep would come. A
    // second swap onto the same price would raise a proration for a move that
    // is not one.
    app(MoveStripeSubscriptionPrice::class)($contract->fresh(), StripeClient::PRORATE_IMMEDIATELY);

    expect($stripe->priceChanges)->toHaveCount(1);
});
