<?php

use App\Actions\BookAddon;
use App\Actions\GrantAddon;
use App\Livewire\Billing;
use App\Models\Customer;
use App\Models\Instance;
use App\Models\Order;
use App\Models\Subscription;
use App\Models\SubscriptionAddon;
use App\Models\User;
use App\Services\Billing\AddonCatalogue;
use App\Services\Billing\CustomDomainAccess;
use App\Services\Billing\DowngradeCheck;
use App\Services\Billing\PlanCatalogue;
use App\Services\Billing\PlanChange;
use Livewire\Livewire;

/**
 * Who may have their own domain.
 *
 * The domain page shipped with no access control at all: the `custom_domain`
 * plan feature was checked nowhere, and every customer on every package could
 * point a domain at their instance. These tests are the owner's rule, stated as
 * behaviour — impossible on the entry package, a module on Team, part of the
 * package on Business and Enterprise — and, as much as anything, they are about
 * what happens on the way DOWN, which is where a right silently outliving the
 * package that granted it does its damage.
 */
function domainCustomer(string $plan, array $instanceAttributes = []): array
{
    $customer = Customer::factory()->create();
    $user = User::factory()->create(['email' => $customer->email, 'email_verified_at' => now()]);
    $customer->update(['user_id' => $user->id]);

    $instance = Instance::factory()->create([
        'customer_id' => $customer->id,
        'plan' => $plan,
        'status' => 'active',
        // Per package: subdomains are unique, and one test walks two packages.
        'subdomain' => 'berger-'.$plan,
        ...$instanceAttributes,
    ]);

    $subscription = Subscription::factory()->plan($plan)->create([
        'customer_id' => $customer->id,
        'instance_id' => $instance->id,
    ]);

    // One test below calls Billing::purchase(), which since Task 5 refuses
    // outright without a Stripe key for the active mode. Without this, that
    // test would see the same "no order" result for the wrong reason — the
    // Stripe guard, not the plan-restriction it names — and prove nothing.
    withStripeSecret();

    return [$customer, $user, $instance, $subscription];
}

/** An instance whose owner has proved the domain is theirs. */
function verifiedDomain(string $domain = 'cloud.berger.at'): array
{
    return ['custom_domain' => $domain, 'domain_token' => 'tok123', 'domain_verified_at' => now()];
}

it('does not sell an own domain to the entry package, at any of the three doors', function () {
    [$customer, $user, , $subscription] = domainCustomer('start');

    // The action, which is the only door that actually books anything.
    expect(fn () => app(BookAddon::class)($subscription, CustomDomainAccess::ADDON))
        ->toThrow(RuntimeException::class, __('billing.custom_domain.not_in_plan', ['plan' => 'Start']));

    // The page, which must not offer what the action would refuse.
    $page = Livewire::actingAs($user)->test(Billing::class);

    expect($page->viewData('addons'))->not->toHaveKey(CustomDomainAccess::ADDON);
    $page->assertDontSee("purchase('addon', 'custom_domain')", false);

    // And the component method behind the button, because a hidden button is
    // markup: a stale tab can still call it.
    Livewire::actingAs($user)->test(Billing::class)->call('purchase', 'addon', CustomDomainAccess::ADDON);

    expect(Order::query()->where('customer_id', $customer->id)->where('addon_key', CustomDomainAccess::ADDON)->exists())
        ->toBeFalse();
});

it('leaves no paid order behind when a grant of the module is refused', function () {
    // The console gives a module away through the same booking action, on a
    // synthetic order it writes first. A refusal after that would leave a paid
    // purchase of nothing on the customer's account — and on their invoice.
    [$customer, , , $subscription] = domainCustomer('start');

    expect(fn () => app(GrantAddon::class)($subscription, admin(), CustomDomainAccess::ADDON, 0))
        ->toThrow(RuntimeException::class);

    expect(Order::query()->where('customer_id', $customer->id)->where('type', 'addon')->exists())->toBeFalse();
});

it('gives Team the domain only once the module is booked', function () {
    [, , , $subscription] = domainCustomer('team');
    $access = app(CustomDomainAccess::class);

    // Bookable, and not yet booked — which is not the same as allowed.
    expect($access->bookable($subscription))->toBeTrue()
        ->and($access->allows($subscription))->toBeFalse()
        ->and($access->refusal($subscription))->not->toBeNull();

    app(BookAddon::class)($subscription, CustomDomainAccess::ADDON);

    expect($access->allows($subscription))->toBeTrue()
        ->and($access->refusal($subscription))->toBeNull();
});

it('gives Business and Enterprise the domain with the package, and does not sell it to them twice', function () {
    $access = app(CustomDomainAccess::class);

    // Enterprise wird seit der neuen Leiter angefragt statt gekauft, aber die
    // Verträge darauf laufen weiter — und die Regel gilt für sie unverändert.
    foreach (['business', 'enterprise'] as $plan) {
        [, $user, , $subscription] = asGrandfathered($plan, fn () => domainCustomer($plan));

        expect($access->allows($subscription))->toBeTrue()
            ->and($access->hasBooked($subscription))->toBeFalse()
            ->and($access->bookable($subscription))->toBeFalse();

        expect(fn () => app(BookAddon::class)($subscription, CustomDomainAccess::ADDON))
            ->toThrow(RuntimeException::class, __('billing.custom_domain.already_included'));

        // The card stays — it says what they have — but it is not a sale.
        $page = Livewire::actingAs($user)->test(Billing::class);

        expect($page->viewData('addons')[CustomDomainAccess::ADDON]['included'])->toBeTrue();
        $page->assertSee(__('billing.addon_included'))
            ->assertDontSee("purchase('addon', 'custom_domain')", false);
    }
});

it('switches the domain off when the package it moved to cannot have one', function () {
    // Team → Start. The domain and the module both came with the package the
    // customer has just left.
    [$customer, , $instance] = domainCustomer('team', verifiedDomain());

    $start = Subscription::factory()->plan('start')->create([
        'customer_id' => $customer->id,
        'instance_id' => $instance->id,
    ]);

    // The module rides along with the change: nothing cancels a booking when a
    // package changes, so settling has to be the thing that notices.
    SubscriptionAddon::create([
        'subscription_id' => $start->id,
        'addon_key' => CustomDomainAccess::ADDON,
        'price_cents' => 900,
        'currency' => 'EUR',
        'quantity' => 1,
        'booked_at' => now(),
    ]);

    expect(PlanChange::settleCustomDomain($start))->toBeTrue();

    $instance->refresh();

    expect($instance->domainIsVerified())->toBeFalse()
        // Back to the address the platform issued it, which is the whole of the
        // fallback: nothing here reimplements how an instance is served.
        ->and($instance->address('clupilot.com'))->toBe('berger-team.clupilot.com')
        ->and($instance->custom_domain)->toBeNull()
        // And it stops being charged for. A feature withdrawn while the module
        // is still on the bill is the complaint we would deserve.
        ->and($start->addons()->active()->count())->toBe(0);
});

it('asks a Business customer before the move, and abides by their answer', function () {
    [$customer, $user, $instance] = domainCustomer('business', verifiedDomain());

    $team = app(PlanCatalogue::class)->sellable()['team'];
    $check = DowngradeCheck::for($customer, $instance, $team, 'team');

    // Possible, and still worth saying out loud beforehand.
    expect($check->allowed)->toBeTrue()
        ->and($check->consequences)->toHaveCount(1)
        ->and($check->consequences[0]['key'])->toBe('custom_domain_addon')
        ->and($check->consequences[0]['replace']['domain'])->toBe('cloud.berger.at');

    // And it is on the card, above the button that would do it — not in a
    // message afterwards.
    Livewire::actingAs($user)->test(Billing::class)
        ->assertSee(__('billing.downgrade_consequence_title'))
        ->assertSee('cloud.berger.at');

    // Declining: the Team contract carries no module, and the domain goes.
    $teamContract = Subscription::factory()->plan('team')->create([
        'customer_id' => $customer->id,
        'instance_id' => $instance->id,
    ]);

    expect(PlanChange::settleCustomDomain($teamContract))->toBeTrue()
        ->and($instance->fresh()->domainIsVerified())->toBeFalse();
});

it('leaves everything alone when the customer keeps the domain by booking the module', function () {
    [$customer, , $instance] = domainCustomer('business', verifiedDomain());

    // The module is booked onto the contract they are moving TO — on Business
    // it would be refused as something they already have.
    $teamContract = Subscription::factory()->plan('team')->create([
        'customer_id' => $customer->id,
        'instance_id' => $instance->id,
    ]);
    app(BookAddon::class)($teamContract, CustomDomainAccess::ADDON);

    expect(PlanChange::settleCustomDomain($teamContract))->toBeFalse()
        ->and($instance->fresh()->domainIsVerified())->toBeTrue()
        ->and($instance->fresh()->address('clupilot.com'))->toBe('cloud.berger.at');

    // And with the module booked there is nothing left to warn about.
    $team = app(PlanCatalogue::class)->sellable()['team'];

    expect(DowngradeCheck::for($customer, $instance, $team, 'team')->consequences)->toBe([]);
});

it('says nothing about a domain the customer has not got', function () {
    // The warning is about a concrete address being switched off. Without one
    // there is nothing to decide, and a paragraph of consequences on every
    // downgrade card teaches people to skip them.
    [$customer, , $instance] = domainCustomer('business');

    $team = app(PlanCatalogue::class)->sellable()['team'];

    expect(DowngradeCheck::for($customer, $instance, $team, 'team')->consequences)->toBe([]);
});

it('keeps the rule in one place, where it can only be changed once', function () {
    // The gap this closed was the same rule living in no place at all. The
    // opposite failure — a copy in the shop, a copy on the price sheet, a copy
    // in the downgrade warning — is the one that ships a customer three
    // different answers, so the config key that says where a domain is
    // impossible is read by exactly one class.
    //
    // Since Task 5 (neue Pakete) `unavailable_on` is a NAMING CONVENTION, not
    // a single rule: `storage_addon.unavailable_on` excludes Enterprise from
    // the storage module, on its own key beside `addons.custom_domain`'s, and
    // AddonCatalogue::availabilityRefusal() is the one class that reads it —
    // the same "exactly one reader" property this test protects, for a second,
    // independent commercial fact. The own-domain rule itself still lives
    // nowhere but CustomDomainAccess.
    $readers = [];

    $files = new RecursiveIteratorIterator(new RecursiveDirectoryIterator(app_path()));

    foreach ($files as $file) {
        if ($file->isFile() && $file->getExtension() === 'php'
            && str_contains((string) file_get_contents($file->getPathname()), 'unavailable_on')) {
            $readers[] = str_replace(base_path().'/', '', $file->getPathname());
        }
    }

    sort($readers);

    expect($readers)->toBe([
        'app/Services/Billing/AddonCatalogue.php',
        'app/Services/Billing/CustomDomainAccess.php',
    ]);
});

/**
 * Office Pro ist die ERWEITERUNG eines Office, das im Paket schon steckt.
 *
 * Start hat keines — die Preistafel zeigt dort ein „—", und ihre eigene
 * Fußnote definiert das als „in diesem Paket nicht verfügbar". Das Portal bot
 * die Pro-Stufe trotzdem für 22,80 € an, weil `collabora_pro` als einziges
 * Modul mit Paketbezug keinen `unavailable_on`-Eintrag hatte. Zwei Aussagen
 * über dieselbe Sache auf einer Seite, und die teurere war die falsche.
 */
it('does not sell the office upgrade to a package that has no office', function () {
    $catalogue = app(AddonCatalogue::class);

    $start = Subscription::factory()->create(['plan' => 'start']);
    $team = Subscription::factory()->create(['plan' => 'team']);

    expect($catalogue->availabilityRefusal($start, 'collabora_pro'))->not->toBeNull()
        // Und die Kehrseite: wo ein Office drin ist, bleibt die Erweiterung
        // buchbar. Eine Sperre, die zu weit greift, wäre derselbe Fehler in
        // die andere Richtung.
        ->and($catalogue->availabilityRefusal($team, 'collabora_pro'))->toBeNull();
});
