<?php

use App\Actions\BookAddon;
use App\Livewire\Billing;
use App\Models\Customer;
use App\Models\Instance;
use App\Models\Subscription;
use App\Models\SubscriptionAddon;
use App\Models\User;
use App\Services\Stripe\FakeStripeClient;
use App\Services\Stripe\StripeClient;
use Illuminate\Contracts\Console\Kernel;
use Illuminate\Support\Carbon;
use Livewire\Livewire;

/**
 * Cancelling a booked module from the portal.
 *
 * The owner's rule was already true of the action — "die Addons können monatlich
 * gekündigt werden", BookAddon::cancelAtPeriodEnd() — and false of the product:
 * the method had no caller anywhere in the interface, so a customer could book a
 * recurring charge in two clicks and had no way at all to stop it.
 *
 * Three things have to hold at once, and they pull against each other:
 * the module keeps running to the end of the term already paid for; Stripe stops
 * billing it from the NEXT cycle; and until the term ends the customer can change
 * their mind without being charged for the privilege.
 */
beforeEach(function () {
    Carbon::setTestNow('2026-03-10 09:00:00');
});

afterEach(function () {
    Carbon::setTestNow();
});

/** A Stripe that answers, with the catalogue mirrored into it. */
function cancellableStripe(): FakeStripeClient
{
    $fake = new FakeStripeClient;
    app()->instance(StripeClient::class, $fake);

    app(Kernel::class)->call('stripe:sync-catalogue');

    return $fake;
}

/**
 * A signed-in customer on a contract with one module booked.
 *
 * @return array{0: User, 1: Subscription, 2: SubscriptionAddon}
 */
function contractWithModule(string $key = 'priority_support'): array
{
    $customer = Customer::factory()->consumer()->create(['email' => 'modul@example.test']);
    $user = User::factory()->create(['email' => $customer->email]);
    $customer->update(['user_id' => $user->id]);

    $contract = Subscription::factory()->plan('team')->create([
        'customer_id' => $customer->id,
        'stripe_subscription_id' => 'sub_module',
        'stripe_item_id' => 'si_plan',
        'current_period_start' => Carbon::parse('2026-03-01 09:00:00'),
        'current_period_end' => Carbon::parse('2026-04-01 09:00:00'),
    ]);

    $instance = Instance::factory()->create([
        'customer_id' => $customer->id,
        'plan' => 'team',
        'status' => 'active',
    ]);

    $contract->update(['instance_id' => $instance->id]);

    $addon = app(BookAddon::class)($contract->refresh(), $key);

    return [$user, $contract->refresh(), $addon->refresh()];
}

it('cancels a module from the portal, and it runs to the end of the paid term', function () {
    $stripe = cancellableStripe();
    [$user, $contract, $addon] = contractWithModule();

    // Booked and billed: Stripe carries an item for it.
    expect($stripe->itemsOn('sub_module'))->toHaveCount(1)
        ->and($addon->stripe_item_id)->not->toBeNull();

    Livewire::actingAs($user)
        ->test(Billing::class)
        ->call('cancelAddon', 'priority_support')
        ->assertDispatched('notify', message: __('billing.addon_cancel_done', [
            'date' => $contract->current_period_end->local()->isoFormat('LL'),
        ]));

    $addon->refresh();

    // Still running — the customer paid for this term and keeps it.
    expect($addon->isActive())->toBeTrue()
        ->and($addon->cancelled_at)->toBeNull()
        ->and($addon->endsAtPeriodEnd())->toBeTrue()
        ->and($addon->cancels_at->toDateTimeString())->toBe('2026-04-01 09:00:00');
});

it('takes the module off the next cycle invoice without raising a credit', function () {
    $stripe = cancellableStripe();
    [$user] = contractWithModule();

    Livewire::actingAs($user)->test(Billing::class)->call('cancelAddon', 'priority_support');

    // Stripe is what raises the cycle invoice, and it no longer has an item for
    // this module — so the next one carries no such line. That is the mechanism,
    // and it is why the item comes off NOW rather than at the boundary: waiting
    // would be a race against Stripe generating that very invoice.
    expect($stripe->itemsOn('sub_module'))->toBeEmpty();

    $removal = collect($stripe->itemCalls)->last();

    // No credit for the term in progress: it was paid in advance and the
    // customer keeps the module through it, so nothing is settled.
    expect($removal['call'])->toBe('remove')
        ->and($removal['proration'])->toBe(StripeClient::PRORATE_NONE);
});

it('lets the customer take the cancellation back while the module is still running', function () {
    $stripe = cancellableStripe();
    [$user] = contractWithModule();

    $component = Livewire::actingAs($user)->test(Billing::class);
    $component->call('cancelAddon', 'priority_support');

    $component->call('resumeAddon', 'priority_support')
        ->assertDispatched('notify', message: __('billing.addon_resumed'));

    $addon = SubscriptionAddon::query()->where('addon_key', 'priority_support')->firstOrFail();

    expect($addon->cancels_at)->toBeNull()
        ->and($addon->isActive())->toBeTrue()
        // Billed again from the next cycle: the item is back on the
        // subscription.
        ->and($stripe->itemsOn('sub_module'))->toHaveCount(1);

    // And not charged for it. The term was already paid for, so putting the
    // item back must settle nothing — `always_invoice` here would bill the
    // customer a second time for days they have already bought.
    $restore = collect($stripe->itemCalls)->last();

    expect($restore['call'])->toBe('add')
        ->and($restore['proration'])->toBe(StripeClient::PRORATE_NONE);
});

it('shows the date it runs until, and the way back, on the card', function () {
    cancellableStripe();
    [$user, $contract] = contractWithModule();

    Livewire::actingAs($user)->test(Billing::class)
        ->assertSee(__('billing.addon_cancel_cta'))
        ->call('cancelAddon', 'priority_support')
        ->assertSee(__('billing.addon_runs_until', [
            'date' => $contract->current_period_end->local()->isoFormat('LL'),
        ]))
        ->assertSee(__('billing.addon_resume_cta'))
        // The card that says "gekündigt" must not also offer to cancel it again.
        ->assertDontSee(__('billing.addon_cancel_cta'));
});

it('will not take a cancellation back once the module has actually ended', function () {
    cancellableStripe();
    [$user, , $addon] = contractWithModule();

    Livewire::actingAs($user)->test(Billing::class)->call('cancelAddon', 'priority_support');

    // The appointment is kept by the scheduled command, exactly as it is in
    // production — not by a hand-written update that would prove nothing.
    Carbon::setTestNow('2026-04-01 09:00:01');
    app(Kernel::class)->call('clupilot:end-cancelled-addons');

    expect($addon->refresh()->cancelled_at)->not->toBeNull();

    Livewire::actingAs($user)->test(Billing::class)
        ->call('resumeAddon', 'priority_support')
        ->assertDispatched('notify', message: __('billing.addon_resume_none'));

    expect($addon->refresh()->cancels_at)->not->toBeNull()
        ->and($addon->isActive())->toBeFalse();
});

it('never cancels a module that belongs to somebody else', function () {
    cancellableStripe();
    [, , $addon] = contractWithModule();

    // A second customer who can post to /livewire/update with any key at all.
    $stranger = Customer::factory()->consumer()->create(['email' => 'fremd@example.test']);
    $strangerUser = User::factory()->create(['email' => $stranger->email]);
    $stranger->update(['user_id' => $strangerUser->id]);

    Livewire::actingAs($strangerUser)
        ->test(Billing::class)
        ->call('cancelAddon', 'priority_support')
        ->assertDispatched('notify', message: __('billing.addon_cancel_none'));

    expect($addon->refresh()->cancels_at)->toBeNull()
        ->and($addon->isActive())->toBeTrue();
});
