<?php

use App\Livewire\Admin\ConfirmDeletePlanDraft;
use App\Livewire\Admin\EditPlanFamily;
use App\Livewire\Admin\Plans;
use App\Livewire\Admin\PlanVersions;
use App\Models\Operator;
use App\Models\PlanFamily;
use App\Models\PlanVersion;
use App\Models\Subscription;
use App\Services\Billing\PlanCatalogue;
use App\Support\LocalTime;
use Livewire\Livewire;
use Spatie\Permission\PermissionRegistrar;

/**
 * The console is where the owner creates and schedules plans. Everything the
 * catalogue refuses has to be refused here too, visibly — an admin page that
 * lets someone try and then throws is worse than one that explains.
 */
function owner(): Operator
{
    return Operator::factory()->role()->create();
}

function teamFamily(): PlanFamily
{
    return PlanFamily::query()->where('key', 'team')->sole();
}

/**
 * Der Entwurf, den `draft()` gerade angelegt hat.
 *
 * Version 3, nicht 2: die Umschaltung auf die neue Leiter hat Version 1
 * beendet und Version 2 veröffentlicht, und ein neuer Entwurf zählt von dort
 * weiter. Die Zahl steht hier einmal, statt in acht Tests.
 */
function teamDraft(): PlanVersion
{
    return teamFamily()->versions()->where('version', 3)->sole();
}

it('is closed to operators without the capability', function () {
    $viewer = Operator::factory()->role('Support')->create();

    // The route middleware lets any operator into the console; the capability
    // is what decides who can see and change what the business sells.
    Livewire::actingAs($viewer, 'operator')->test(PlanVersions::class, ['uuid' => teamFamily()->uuid])
        ->assertForbidden();

    // Including the pages themselves — prices, drafts and unreleased plans are
    // not something to leave readable to anyone who types the URL.
    $this->actingAs($viewer, 'operator')->get(route('admin.plans'))->assertForbidden();
    $this->actingAs($viewer, 'operator')->get(route('admin.plans.versions', teamFamily()->uuid))->assertForbidden();

    $this->actingAs(owner(), 'operator')->get(route('admin.plans'))->assertOk();
});

it('creates a plan line, and refuses a key that is not an identifier', function () {
    Livewire::actingAs(owner(), 'operator')->test(Plans::class)
        ->set('key', 'Agentur Paket')
        ->set('name', 'Agentur')
        ->set('tier', 5)
        ->call('save')
        ->assertHasErrors('key');

    Livewire::actingAs(owner(), 'operator')->test(Plans::class)
        ->set('key', 'agency')
        ->set('name', 'Agentur')
        ->set('tier', 5)
        ->call('save')
        ->assertHasNoErrors();

    $family = PlanFamily::query()->where('key', 'agency')->sole();

    expect($family->name)->toBe('Agentur')
        ->and($family->tier)->toBe(5)
        // Nothing to sell yet: a family without a published version is not
        // in the shop, whatever its switch says.
        ->and(app(PlanCatalogue::class)->isSellable('agency'))->toBeFalse();
});

it('takes a plan out of the shop without touching anyone\'s contract', function () {
    $contract = Subscription::factory()->plan('team')->create();

    Livewire::actingAs(owner(), 'operator')->test(Plans::class)->call('toggleSales', teamFamily()->uuid);

    expect(app(PlanCatalogue::class)->isSellable('team'))->toBeFalse()
        ->and(teamFamily()->sales_enabled)->toBeFalse()
        // The customer keeps everything they bought.
        ->and($contract->fresh()->price_cents)->toBe(7900)
        ->and($contract->fresh()->status)->toBe('active');

    Livewire::actingAs(owner(), 'operator')->test(Plans::class)->call('toggleSales', teamFamily()->uuid);
    expect(app(PlanCatalogue::class)->isSellable('team'))->toBeTrue();
});

it('takes a plan off the price sheet without touching whether it sells at all', function () {
    // Der zweite Schalter, unabhängig vom ersten: `internal` entscheidet, ob
    // eine Familie im Laden steht, `sales_enabled` bleibt unberührt — anders
    // als toggleSales() macht dieser Schalter ein Paket nicht unverkäuflich,
    // er nimmt es nur aus dem Preisblatt.
    $team = teamFamily();
    expect($team->internal)->toBeFalse();

    Livewire::actingAs(owner(), 'operator')->test(Plans::class)->call('toggleInternal', $team->uuid);

    expect($team->fresh()->internal)->toBeTrue()
        ->and($team->fresh()->sales_enabled)->toBeTrue()
        ->and(array_keys(app(PlanCatalogue::class)->sellable()))->not->toContain('team')
        ->and(array_keys(app(PlanCatalogue::class)->grantable()))->toContain('team');

    Livewire::actingAs(owner(), 'operator')->test(Plans::class)->call('toggleInternal', $team->uuid);
    expect($team->fresh()->internal)->toBeFalse()
        ->and(array_keys(app(PlanCatalogue::class)->sellable()))->toContain('team');
});

it('re-checks the capability on the internal switch rather than trusting the mount', function () {
    // Wie beim vorhandenen Muster für reopen(): die Seite ist offen und
    // rechtmäßig aufgerufen, dann wird das Konto herabgestuft.
    $user = owner();
    $page = Livewire::actingAs($user, 'operator')->test(Plans::class);

    $user->syncRoles(['Support']);
    app(PermissionRegistrar::class)->forgetCachedPermissions();

    $page->call('toggleInternal', teamFamily()->uuid)->assertForbidden();

    expect(teamFamily()->internal)->toBeFalse();
});

it('drafts a version prefilled from the last one, priced for both terms', function () {
    $page = Livewire::actingAs(owner(), 'operator')->test(PlanVersions::class, ['uuid' => teamFamily()->uuid]);

    // Prefilled, so "same plan, new price" is one edit rather than nine.
    $page->assertSet('ramMb', 8192)
        ->assertSet('quotaGb', 85)
        ->assertSet('monthlyPrice', '79,00');

    // The monthly price and the free months. The yearly total is derived from
    // them — two free means ten months paid, 199,00 × 10 — because a typed total
    // left nothing knowing WHY it was that figure, and no page could then say
    // "zwei Monate gratis" without working it out a second time by hand.
    $page->set('monthlyPrice', '199,00')->set('freeMonths', 2)->call('draft')->assertHasNoErrors();

    $draft = teamDraft();

    expect($draft->isPublished())->toBeFalse()
        ->and($draft->ram_mb)->toBe(8192)
        ->and($draft->free_months)->toBe(2)
        ->and($draft->priceFor('monthly')->amount_cents)->toBe(19900)
        ->and($draft->priceFor('yearly')->amount_cents)->toBe(199000)
        // A draft is not on sale, so the shop is unchanged.
        ->and(app(PlanCatalogue::class)->sellable()['team']['price_cents'])->toBe(7900);
});

it('refuses a mistyped figure with a message instead of overflowing the column', function () {
    Livewire::actingAs(owner(), 'operator')->test(PlanVersions::class, ['uuid' => teamFamily()->uuid])
        // A stray keystroke on the price field: unbounded, this overflows an
        // unsigned int and the owner gets a 500 with no idea which number
        // was wrong.
        ->set('monthlyPrice', 'neunzehn Euro')
        ->set('ramMb', 999999999)
        ->call('draft')
        ->assertHasErrors(['monthlyPrice', 'ramMb']);

    // Zwei: die beendete erste Fassung und die, die seit der Umschaltung
    // verkauft wird. Der abgelehnte Entwurf ist keine dritte geworden.
    expect(teamFamily()->versions()->count())->toBe(2);
});

it('does not call a version live when the plan is withdrawn', function () {
    PlanFamily::query()->where('key', 'team')->update(['sales_enabled' => false]);

    // The window is still open, but the kill switch overrides it — saying
    // "on sale" here would contradict what the customer is actually offered.
    Livewire::actingAs(owner(), 'operator')->test(PlanVersions::class, ['uuid' => teamFamily()->uuid])
        ->assertSee(__('plans.badge_withdrawn'))
        ->assertSee(__('plans.family_withdrawn_note'))
        ->assertDontSee(__('plans.badge_live'));
});

it('refuses a feature key we have no label for', function () {
    Livewire::actingAs(owner(), 'operator')->test(PlanVersions::class, ['uuid' => teamFamily()->uuid])
        // The checkboxes only offer real keys, but a request can carry anything.
        ->set('features', ['managed_updates', 'free_unicorns'])
        ->call('draft')
        ->assertHasErrors('features.1');
});

it('refuses a performance class we have no label for', function () {
    // Published, this would be frozen and shown to customers as a raw
    // translation key for the life of the version.
    Livewire::actingAs(owner(), 'operator')->test(PlanVersions::class, ['uuid' => teamFamily()->uuid])
        ->set('performance', 'enhnaced')
        ->call('draft')
        ->assertHasErrors('performance');
});

it('refuses a disk smaller than the storage it has to hold', function () {
    Livewire::actingAs(owner(), 'operator')->test(PlanVersions::class, ['uuid' => teamFamily()->uuid])
        ->set('quotaGb', 500)
        ->set('diskGb', 100)
        ->call('draft')
        ->assertHasErrors('diskGb');
});

it('publishes a draft straight over the running version, announcing the handover first', function () {
    $page = Livewire::actingAs(owner(), 'operator')->test(PlanVersions::class, ['uuid' => teamFamily()->uuid]);
    $page->set('monthlyPrice', '199,00')->call('draft');

    $draft = teamDraft();
    $live = app(PlanCatalogue::class)->currentVersion('team');
    $at = now()->addHour();

    // Publishing ends another version's sale, so the form has to say which one
    // and when — before the button is pressed, not in the list afterwards.
    $page->call('choose', $draft->uuid)
        ->set('availableFrom', LocalTime::toField($at))
        ->assertSee(__('plans.handover_note', [
            'version' => $live->version,
            'at' => $at->copy()->local()->isoFormat('L LT'),
        ]));

    // No closing the running version first: that was the trap. A replacement
    // that then failed to publish left the plan off sale with no way back.
    $page->call('publish')->assertHasNoErrors();

    expect($draft->fresh()->isPublished())->toBeTrue()
        ->and($live->fresh()->available_until->eq($draft->fresh()->available_from))->toBeTrue()
        ->and(app(PlanCatalogue::class)->currentVersion('team')->version)->toBe(2)
        ->and(app(PlanCatalogue::class)->currentVersion('team', now()->addHours(2))->version)->toBe(3);
});

it('puts a closed version back on sale from the version list', function () {
    $page = Livewire::actingAs(owner(), 'operator')->test(PlanVersions::class, ['uuid' => teamFamily()->uuid]);
    $live = app(PlanCatalogue::class)->currentVersion('team');

    $page->call('close', $live->uuid);

    expect(app(PlanCatalogue::class)->isSellable('team'))->toBeFalse();

    // Closing was one-way until now, so the way back has to be on the page.
    $page->assertSee(__('plans.reopen'))
        ->call('reopen', $live->uuid)
        ->assertHasNoErrors();

    expect(app(PlanCatalogue::class)->isSellable('team'))->toBeTrue()
        ->and(app(PlanCatalogue::class)->currentVersion('team')->version)->toBe($live->version)
        ->and($live->fresh()->available_until)->toBeNull();
});

it('refuses to reopen a version whose successor is selling, in the console\'s own words', function () {
    // Eine Stunde weiter, bevor irgendetwas beginnt. Das Formularfeld ist auf
    // die Minute genau (`datetime-local` kennt keine Sekunden), und die
    // laufende Version beginnt in der Sekunde, in der die Umschaltung auf die
    // neue Leiter lief — ein abgeschnittenes „jetzt" fiele in derselben Minute
    // noch davor, und die Veröffentlichung überlappte die laufende Version,
    // statt sie abzulösen.
    $this->travelTo(now()->addHour());

    $page = Livewire::actingAs(owner(), 'operator')->test(PlanVersions::class, ['uuid' => teamFamily()->uuid]);
    $page->set('monthlyPrice', '199,00')->call('draft');

    $draft = teamDraft();
    $live = app(PlanCatalogue::class)->currentVersion('team');

    $page->call('choose', $draft->uuid)
        ->set('availableFrom', LocalTime::toField(now()))
        ->call('publish')
        ->assertHasNoErrors();

    $page->call('reopen', $live->uuid)
        ->assertHasErrors('availableFrom')
        ->assertSee(__('plans.reopen_blocked'))
        // The catalogue explains itself in English, at the length a log entry
        // deserves. That sentence must not reach a German console.
        ->assertDontSee('overlaps');

    expect($live->fresh()->available_until)->not->toBeNull()
        ->and(app(PlanCatalogue::class)->currentVersion('team')->version)->toBe(3);
});

it('will not reopen a draft, and says why instead of doing nothing', function () {
    $page = Livewire::actingAs(owner(), 'operator')->test(PlanVersions::class, ['uuid' => teamFamily()->uuid]);
    $page->call('draft');

    $draft = teamDraft();

    // The list offers no such button for a draft, but a request can carry
    // anything, and "was never on sale" is a different refusal from "something
    // newer is selling".
    $page->call('reopen', $draft->uuid)
        ->assertHasErrors('availableFrom')
        ->assertSee(__('plans.reopen_draft'));

    expect($draft->fresh()->isPublished())->toBeFalse()
        ->and($draft->fresh()->available_until)->toBeNull();
});

it('re-checks the capability on reopen rather than trusting the mount', function () {
    $user = owner();
    $live = app(PlanCatalogue::class)->currentVersion('team');
    app(PlanCatalogue::class)->schedule($live, $live->available_from, now());

    $page = Livewire::actingAs($user, 'operator')->test(PlanVersions::class, ['uuid' => teamFamily()->uuid]);

    // The page is open and legitimate, and then the account is demoted.
    $user->syncRoles(['Support']);
    app(PermissionRegistrar::class)->forgetCachedPermissions();

    $page->call('reopen', $live->uuid)->assertForbidden();

    expect($live->fresh()->available_until)->not->toBeNull()
        ->and(app(PlanCatalogue::class)->isSellable('team'))->toBeFalse();
});

it('will not publish a window that ends before it starts', function () {
    $page = Livewire::actingAs(owner(), 'operator')->test(PlanVersions::class, ['uuid' => teamFamily()->uuid]);
    $page->call('draft');

    $draft = teamDraft();

    $page->call('choose', $draft->uuid)
        ->set('availableFrom', LocalTime::toField(now()->addWeek()))
        ->set('availableUntil', LocalTime::toField(now()))
        ->call('publish')
        ->assertHasErrors('availableUntil');
});

it('discards a draft through the modal, and never a published version', function () {
    $page = Livewire::actingAs(owner(), 'operator')->test(PlanVersions::class, ['uuid' => teamFamily()->uuid]);
    $page->call('draft');

    $draft = teamDraft();

    Livewire::actingAs(owner(), 'operator')->test(ConfirmDeletePlanDraft::class, ['uuid' => $draft->uuid])
        ->call('delete');

    expect(PlanVersion::query()->whereKey($draft->id)->exists())->toBeFalse();

    // The published one cannot even open the dialog — customers are on it.
    $live = app(PlanCatalogue::class)->currentVersion('team');
    Livewire::actingAs(owner(), 'operator')->test(ConfirmDeletePlanDraft::class, ['uuid' => $live->uuid])
        ->assertForbidden();
});

it('does not discard a draft that was published while the dialog was open', function () {
    $page = Livewire::actingAs(owner(), 'operator')->test(PlanVersions::class, ['uuid' => teamFamily()->uuid]);
    $page->call('draft');

    $draft = teamDraft();

    // The dialog opens on a draft...
    $modal = Livewire::actingAs(owner(), 'operator')->test(ConfirmDeletePlanDraft::class, ['uuid' => $draft->uuid]);

    // ...and someone publishes it before the button is clicked.
    $live = app(PlanCatalogue::class)->currentVersion('team');
    app(PlanCatalogue::class)->schedule($live, $live->available_from, now());
    app(PlanCatalogue::class)->publish($draft, now());

    $modal->call('delete');

    // Customers can be contracted to it by now, so it has to survive.
    expect(PlanVersion::query()->whereKey($draft->id)->exists())->toBeTrue();
});

it('authorises the modal itself, not just the page that opens it', function () {
    $page = Livewire::actingAs(owner(), 'operator')->test(PlanVersions::class, ['uuid' => teamFamily()->uuid]);
    $page->call('draft');

    $draft = teamDraft();
    $viewer = Operator::factory()->role('Support')->create();

    // Modals are reachable without the page's guards.
    Livewire::actingAs($viewer, 'operator')->test(ConfirmDeletePlanDraft::class, ['uuid' => $draft->uuid])
        ->assertForbidden();
});

it('lists a withdrawn plan as withdrawn, not as missing', function () {
    PlanFamily::query()->where('key', 'team')->update(['sales_enabled' => false]);

    Livewire::actingAs(owner(), 'operator')->test(Plans::class)
        ->assertSee('team')
        ->assertSee(__('plans.withdrawn_badge'));
});

it('shows a normal sellable plan as on sale', function () {
    // Der Normalfall — auf den meisten Zeilen der Fall — hatte bisher keinen
    // eigenen Test: die drei anderen Zustände sind unten belegt, dieser nicht.
    // Bräche $selling oder die erste Bedingung im match(true), verschwände ein
    // verkäufliches Paket unbemerkt aus der Liste, und ausgerechnet der
    // häufigste Zustand wäre der einzige ungeprüfte.
    //
    // Business hängt hier nicht zufällig im Verkauf: geprüft wird, dass es
    // weder internal noch zurückgezogen ist und tatsächlich in sellable()
    // steht — der echte Normalfall, kein Schalter extra dafür umgelegt.
    $business = PlanFamily::query()->where('key', 'business')->sole();
    expect($business->internal)->toBeFalse()
        ->and($business->sales_enabled)->toBeTrue()
        ->and(array_keys(app(PlanCatalogue::class)->sellable()))->toContain('business');

    $html = Livewire::actingAs(owner(), 'operator')->test(Plans::class)->html();

    // assertSee(__('plans.on_sale')) allein bewiese nichts: die Spaltenüber-
    // schrift ("plans.live_version") übersetzt zufällig auf denselben Satz
    // "Im Verkauf" und stünde selbst dann auf der Seite, wenn keine einzige
    // Zeile die Plakette zeigte. Die Überschrift kommt genau einmal vor —
    // mehr als das beweist, dass mindestens eine Zeile die Plakette selbst
    // trägt.
    expect(substr_count($html, __('plans.on_sale')))->toBeGreaterThan(1);
});

it('shows an internal plan as console-only, not as nothing available', function () {
    // Intern (`test`) ist internal=true mit einer laufenden Version — die
    // Liste fragte bisher nur "steht die Familie im Verkaufskatalog?" und
    // fiel deshalb auf "Nichts verfügbar" zurück, obwohl eine Version läuft.
    // Das war der Widerspruch, den der Betreiber gemeldet hat.
    Livewire::actingAs(owner(), 'operator')->test(Plans::class)
        ->assertSee(__('plans.console_only'))
        ->assertDontSee(__('plans.nothing_available'));
});

it('lets the kill switch outrank the console-only label', function () {
    // "Zurückgezogen" schlägt "nur über die Konsole": der Notausschalter ist
    // die stärkere Aussage, Teil der Aufgabe. Enterprise ist seit der zweiten
    // Migration ebenfalls internal+live, deshalb wird über die Häufigkeit
    // geprüft statt über die bloße Anwesenheit des Textes.
    $before = substr_count(
        Livewire::actingAs(owner(), 'operator')->test(Plans::class)->html(),
        __('plans.console_only')
    );

    PlanFamily::query()->where('key', 'test')->update(['sales_enabled' => false]);

    $page = Livewire::actingAs(owner(), 'operator')->test(Plans::class);

    expect(substr_count($page->html(), __('plans.console_only')))->toBe($before - 1);

    $page->assertSee(__('plans.withdrawn_badge'));
});

it('shows nothing available only when a sellable plan has no version in its window', function () {
    $live = app(PlanCatalogue::class)->currentVersion('team');
    app(PlanCatalogue::class)->schedule($live, $live->available_from, now());

    Livewire::actingAs(owner(), 'operator')->test(Plans::class)
        ->assertSee(__('plans.nothing_available'));
});

it('does not call an internal plan on sale on its own version page either', function () {
    // Der zweite Teil desselben Widerspruchs: die Versionsseite zeigte "Im
    // Verkauf", weil sie nur prüfte, ob das Fenster offen und der
    // Verkaufsschalter an ist — ohne `internal` zu fragen.
    Livewire::actingAs(owner(), 'operator')->test(PlanVersions::class, ['uuid' => PlanFamily::query()->where('key', 'test')->value('uuid')])
        ->assertSee(__('plans.console_only'))
        ->assertDontSee(__('plans.badge_live'));
});

it('recommends a plan, and recommending a second one clears the first', function () {
    $team = teamFamily();
    $business = PlanFamily::query()->where('key', 'business')->sole();

    Livewire::actingAs(owner(), 'operator')->test(EditPlanFamily::class, ['uuid' => $team->uuid])
        ->set('recommended', true)
        ->call('save');

    expect($team->fresh()->is_recommended)->toBeTrue();

    // "Recommended" is singular by nature: marking a second plan is not two
    // recommendations, it is none — so the first must clear the moment the
    // second is set.
    Livewire::actingAs(owner(), 'operator')->test(EditPlanFamily::class, ['uuid' => $business->uuid])
        ->set('recommended', true)
        ->call('save');

    expect($business->fresh()->is_recommended)->toBeTrue()
        ->and($team->fresh()->is_recommended)->toBeFalse();
});

it('opens the modal already showing the current mark, and can clear it entirely', function () {
    $team = teamFamily();
    PlanFamily::query()->whereKey($team->id)->update(['is_recommended' => true]);

    Livewire::actingAs(owner(), 'operator')->test(EditPlanFamily::class, ['uuid' => $team->uuid])
        ->assertSet('recommended', true)
        ->set('recommended', false)
        ->call('save');

    expect($team->fresh()->is_recommended)->toBeFalse();
});

it('saves the audience line and note beside the plan', function () {
    Livewire::actingAs(owner(), 'operator')->test(EditPlanFamily::class, ['uuid' => teamFamily()->uuid])
        ->set('audience', 'Für Redaktionen')
        ->set('note', 'Testnotiz')
        ->call('save');

    expect(teamFamily())
        ->audience->toBe('Für Redaktionen')
        ->note->toBe('Testnotiz');
});

it('closes the marketing modal to operators without the capability', function () {
    // Modals are reachable without the page's guards — same reason
    // ConfirmDeletePlanDraft checks this on itself.
    Livewire::actingAs(Operator::factory()->role('Support')->create(), 'operator')
        ->test(EditPlanFamily::class, ['uuid' => teamFamily()->uuid])
        ->assertForbidden();
});

it('shows the console entry only to those who may use it', function () {
    // Der Katalog aendert sich selten und steht seit dem 4.8.2026 nicht mehr in
    // der Seitenleiste, sondern als Kachel im Einrichtungsbereich. Die
    // Zusicherung ist dieselbe geblieben — wer ihn nicht pflegen darf, sieht
    // auch den Weg dorthin nicht.
    $this->actingAs(owner(), 'operator')->get(route('admin.setup'))->assertSee(__('admin.nav.plans'));

    $this->actingAs(Operator::factory()->role('Support')->create(), 'operator')
        ->get(route('admin.setup'))
        ->assertDontSee(__('admin.nav.plans'));
});
