<?php

use App\Actions\BookAddon;
use App\Actions\GrantAddon;
use App\Actions\OpenSubscription;
use App\Livewire\Billing;
use App\Models\Customer;
use App\Models\Instance;
use App\Models\Operator;
use App\Models\Order;
use App\Models\Subscription;
use App\Models\SubscriptionAddon;
use App\Models\User;
use App\Services\Billing\AddonCatalogue;
use Livewire\Livewire;

/**
 * Buying the same module twice.
 *
 * The unique index on `subscription_addons` guards a single ORDER, which is the
 * wrong unit: two orders for the same module on one contract both went through,
 * so a stale tab or a double click charged the customer twice for one thing.
 *
 * The other half of that is just as real in the opposite direction. Storage is
 * deliberately sold in packs and two bookings are the point of it — refusing
 * the second would be a lost sale, not a saved customer. So which modules may be
 * held once and which may be stacked is declared per module in
 * config/provisioning.php, and these prove it is obeyed everywhere a module can
 * be booked: the action, the shop, and the operator giving one away.
 */

/** A contract to book modules onto. */
function entitlementContract(string $plan = 'team'): Subscription
{
    $order = Order::factory()->create(['plan' => $plan, 'datacenter' => 'fsn', 'status' => 'paid']);

    return app(OpenSubscription::class)($order);
}

/** A portal customer on a package, with the machine their contract pays for. */
function entitlementShopper(string $plan = 'team'): array
{
    $customer = Customer::factory()->create();
    $user = User::factory()->create(['email' => $customer->email]);
    $order = Order::factory()->withSubscription()->for($customer)->create(['plan' => $plan]);
    $instance = Instance::factory()->for($customer)->create([
        'order_id' => $order->id,
        'plan' => $plan,
        'status' => 'active',
    ]);

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

    // The tests that buy through the shop call Billing::purchase(), which
    // since Task 5 refuses outright without a Stripe key for the active mode.
    withStripeSecret();

    return [$customer, $user, $order->subscription->fresh()];
}

it('declares for every module it sells whether it may be held once or stacked', function () {
    $catalogue = app(AddonCatalogue::class);
    $declared = array_merge(
        collect((array) config('provisioning.addons'))->map(fn ($a) => $a['sold_as'] ?? null)->all(),
        [AddonCatalogue::STORAGE => config('provisioning.storage_addon.sold_as')],
    );

    // Undeclared is not a state this catalogue may ship in. It costs money in
    // both directions — a double charge or a lost sale — and the fallback in
    // soldAs() is a safety net, not somewhere to leave the decision.
    foreach ($declared as $key => $soldAs) {
        expect($soldAs)->toBeIn([AddonCatalogue::ENTITLEMENT, AddonCatalogue::QUANTITY], "module {$key}");
    }

    // And the one that is genuinely a pack is the one declared as a pack.
    expect($catalogue->isEntitlement(AddonCatalogue::STORAGE))->toBeFalse()
        ->and($catalogue->isEntitlement('priority_support'))->toBeTrue()
        ->and($catalogue->isEntitlement('extra_backups'))->toBeTrue()
        ->and($catalogue->isEntitlement('collabora_pro'))->toBeTrue()
        ->and($catalogue->isEntitlement('custom_domain'))->toBeTrue();
});

it('refuses to book an entitlement a second time on the same contract', function () {
    $subscription = entitlementContract();

    app(BookAddon::class)($subscription, 'priority_support');

    // A second, different order — which is exactly what a stale tab produces,
    // and what the per-order unique index never saw.
    $second = Order::factory()->for($subscription->customer)->create(['type' => 'addon', 'addon_key' => 'priority_support', 'status' => 'paid']);

    expect(fn () => app(BookAddon::class)($subscription, 'priority_support', 1, $second))
        ->toThrow(RuntimeException::class, __('billing.addon_already_booked', [
            'module' => __('billing.addon.priority_support.name'),
        ]));

    expect($subscription->addons()->count())->toBe(1);
});

it('still books the same order twice as one module, because that is a retry', function () {
    $subscription = entitlementContract();
    $order = Order::factory()->for($subscription->customer)->create(['type' => 'addon', 'addon_key' => 'collabora_pro', 'status' => 'paid']);

    $first = app(BookAddon::class)($subscription, 'collabora_pro', 1, $order);
    $again = app(BookAddon::class)($subscription, 'collabora_pro', 1, $order);

    // A webhook delivered twice is one purchase arriving again, not a second
    // one: it gets its booking back rather than an error.
    expect($again->id)->toBe($first->id)
        ->and($subscription->addons()->count())->toBe(1);
});

it('lets an entitlement be booked again once the customer has cancelled it', function () {
    $subscription = entitlementContract();

    $addon = app(BookAddon::class)($subscription, 'extra_backups');
    app(BookAddon::class)->cancel($addon);

    // The rule is about what a contract HAS, not about what it has ever had —
    // otherwise cancelling a module would be irreversible.
    app(BookAddon::class)($subscription, 'extra_backups');

    expect($subscription->addons()->active()->count())->toBe(1)
        ->and(SubscriptionAddon::query()->count())->toBe(2);
});

it('stacks a module that is sold in packs, and totals it as the sum of its bookings', function () {
    $subscription = entitlementContract();
    $pack = (int) app(AddonCatalogue::class)->priceCents(AddonCatalogue::STORAGE);

    app(BookAddon::class)($subscription, AddonCatalogue::STORAGE);
    app(BookAddon::class)($subscription, AddonCatalogue::STORAGE, 2);

    $row = app(AddonCatalogue::class)->forSubscription($subscription->fresh())[AddonCatalogue::STORAGE];

    expect($subscription->addons()->active()->count())->toBe(2)
        ->and($row['quantity'])->toBe(3)
        ->and($row['monthly_cents'])->toBe($pack * 3)
        ->and($row['entitlement'])->toBeFalse();
});

it('refuses a second purchase of an entitlement through the shop, in words', function () {
    [$customer, $user, $subscription] = entitlementShopper();

    app(BookAddon::class)($subscription, 'collabora_pro');

    Livewire::actingAs($user)->test(Billing::class)
        ->call('purchase', 'addon', 'collabora_pro')
        ->assertDispatched('notify', message: __('billing.addon_already_booked', [
            'module' => __('billing.addon.collabora_pro.name'),
        ]));

    // No order, so nothing to charge for: the refusal is the point, the
    // sentence is only how the customer finds out.
    expect(Order::query()->where('customer_id', $customer->id)->where('type', 'addon')->exists())->toBeFalse();
});

it('refuses a second purchase while the first is still sitting in the cart', function () {
    [$customer, $user] = entitlementShopper();

    $component = Livewire::actingAs($user)->test(Billing::class);
    $component->call('purchase', 'addon', 'priority_support');
    $component->call('purchase', 'addon', 'priority_support')
        ->assertDispatched('notify', message: __('billing.addon_in_cart', [
            'module' => __('billing.addon.priority_support.name'),
        ]));

    // The double click, which is where the second charge came from.
    expect(Order::query()->where('customer_id', $customer->id)->where('type', 'addon')->count())->toBe(1);
});

it('stops offering an entitlement the customer already has, and keeps offering packs', function () {
    [, $user, $subscription] = entitlementShopper();

    app(BookAddon::class)($subscription, 'collabora_pro');

    Livewire::actingAs($user)->test(Billing::class)
        ->assertSee(__('billing.addon_booked'))
        // Storage is a pack and has its own card, which never stops offering.
        ->assertSee(__('billing.storage_cta', ['gb' => config('provisioning.storage_addon.gb')]));

    $rows = Livewire::actingAs($user)->test(Billing::class)->viewData('addons');

    expect($rows['collabora_pro']['booked'])->toBeTrue()
        ->and($rows['collabora_pro']['entitlement'])->toBeTrue();
});

it('marks a module waiting in the cart so the card stops offering it', function () {
    [, $user] = entitlementShopper();

    Livewire::actingAs($user)->test(Billing::class)->call('purchase', 'addon', 'extra_backups');

    $component = Livewire::actingAs($user)->test(Billing::class);

    expect($component->viewData('addons')['extra_backups']['in_cart'])->toBeTrue();

    $component->assertSee(__('billing.addon_in_cart_badge'));
});

it('refuses to grant an entitlement the customer is already paying for', function () {
    $subscription = entitlementContract();
    $owner = Operator::factory()->create();

    app(BookAddon::class)($subscription, 'priority_support');

    expect(fn () => app(GrantAddon::class)(
        subscription: $subscription,
        grantedBy: $owner,
        addonKey: 'priority_support',
        priceCents: 0,
    ))->toThrow(RuntimeException::class, __('billing.addon_already_booked', [
        'module' => __('billing.addon.priority_support.name'),
    ]));

    // And the synthetic order the grant would have hung off it is gone with it:
    // an order left behind is a charge for a module nobody got.
    expect($subscription->addons()->count())->toBe(1)
        ->and(Order::query()->where('type', 'addon')->count())->toBe(0);
});

it('still lets an operator grant a second pack of storage', function () {
    $subscription = entitlementContract();
    $owner = Operator::factory()->create();

    app(BookAddon::class)($subscription, AddonCatalogue::STORAGE);

    app(GrantAddon::class)(
        subscription: $subscription,
        grantedBy: $owner,
        addonKey: AddonCatalogue::STORAGE,
        priceCents: 0,
    );

    expect($subscription->addons()->active()->count())->toBe(2);
});
