<?php

use App\Models\PlanFamily;
use App\Models\PlanVersion;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Str;

/**
 * The public price sheet reads the catalogue.
 *
 * It used to be written into the page by hand, and the two had already drifted:
 * the site advertised 249 € for a plan the checkout charged 399 € for. These
 * tests exist so that cannot come back — and so a catalogue that is broken
 * takes the price sheet down, never the whole front page.
 *
 * The sheet is three blocks — what every package has, what tells them apart,
 * what costs extra — and the tests below are mostly about the seams between
 * them: the same words mean different things in each block, so a promise made
 * once above the table must not reappear as a row inside it, and a cell that
 * says nothing must be distinguishable from one that says "nine euros".
 */

/**
 * The comparison table's own markup.
 *
 * A search across the whole page cannot tell the blocks apart — "Überwachung
 * rund um die Uhr" is a promise above the table and a card bullet beside it,
 * and the point of the rebuild is that it is no longer a row inside it.
 */
/**
 * A net catalogue figure as the page prints it: gross, in the page's format.
 *
 * Computed here rather than written down, for the same reason the page does not
 * write it down — the rate is console-editable, and a "9 €" in a test is a test
 * that passes on the day somebody changes the VAT rate and the page goes wrong.
 */
function priceSheetGross(int $netCents): string
{
    $cents = (int) round($netCents * (1 + App\Support\CompanyProfile::taxRate() / 100));

    $amount = $cents % 100 === 0
        ? number_format($cents / 100, 0, ',', '.')
        : number_format($cents / 100, 2, ',', '.');

    return $amount."\u{00A0}€";
}

function priceSheetTable(string $html): string
{
    $section = substr($html, (int) strpos($html, 'id="preise"'));
    $start = strpos($section, '<table');

    if ($start === false) {
        return '';
    }

    return substr($section, $start, (int) strpos($section, '</table>') - $start);
}

/** One row of that table, by its heading — the cells of a single feature. */
function priceSheetRow(string $html, string $label): string
{
    $table = priceSheetTable($html);
    $at = strpos($table, '>'.$label.'</th>');

    if ($at === false) {
        return '';
    }

    $start = (int) strrpos(substr($table, 0, $at), '<tr');

    return substr($table, $start, (int) strpos($table, '</tr>', $at) - $start);
}

it('quotes the price the catalogue would actually charge', function () {
    $team = PlanFamily::query()->where('key', 'team')->firstOrFail();
    // Die Fassung IM VERKAUF, nicht die erste der Familie: seit der Umschaltung
    // auf die neue Leiter steht daneben eine beendete, und die Seite quotiert
    // selbstverständlich die laufende.
    $version = $team->versionAt();
    $monthly = $version->prices()->where('term', 'monthly')->firstOrFail();

    $page = $this->get('/')->assertOk();

    // Whole euros, German grouping, non-breaking space — the exact string the
    // sheet renders, so a formatting change cannot slip past this test.
    $page->assertSee(number_format($monthly->amount_cents / 100, 0, ',', '.')."\u{00A0}€", false);
    $page->assertSee('bis '.$version->seats);

    // The included traffic is sold and metered but was simply never printed.
    $page->assertSee('Datenvolumen');
    $page->assertSee($version->traffic_gb >= 1000 && $version->traffic_gb % 1000 === 0
        ? ($version->traffic_gb / 1000).' TB'
        : $version->traffic_gb.' GB');
});

it('follows the catalogue when the owner changes a price', function () {
    // The point of the whole exercise: one place to change a price.
    // Beide Laufzeiten, wie die Konsole sie ändert: der Jahrespreis wird aus dem
    // Monatspreis und den zwei Freimonaten gerechnet, also zehn Monate. Nur eine
    // Zeile umzuschreiben ließe die Karte „entspricht … pro Monat" auf dem alten
    // Betrag stehen — und genau der soll hier verschwinden.
    $version = PlanFamily::query()->where('key', 'team')->firstOrFail()->versionAt();

    DB::table('plan_prices')->where('plan_version_id', $version->id)
        ->where('term', 'monthly')->update(['amount_cents' => 21500]);
    DB::table('plan_prices')->where('plan_version_id', $version->id)
        ->where('term', 'yearly')->update(['amount_cents' => 215000]);

    $this->get('/')
        ->assertOk()
        ->assertSee("215\u{00A0}€", false)
        ->assertDontSee("79\u{00A0}€", false);
});

it('keeps the front page up when the catalogue cannot be read', function () {
    // A second version of the same plan on sale at the same moment. The
    // catalogue refuses to guess which one is being sold — correctly, because
    // guessing would mean quoting a price at random. The marketing page must
    // survive that: an outage in the shop is not an outage of the company.
    Log::spy();

    $team = PlanFamily::query()->where('key', 'team')->firstOrFail();
    // Die laufende Fassung geklont, nicht die erste: deren Nummer ist längst
    // vergeben, und ein Klon mit derselben Nummer scheiterte am eindeutigen
    // Schlüssel, statt zwei Fassungen gleichzeitig in den Verkauf zu stellen.
    $original = $team->versionAt();

    $clone = collect($original->getAttributes())->except('id')->all();
    $clone['uuid'] = (string) Str::uuid();
    $clone['version'] = $original->version + 1;
    DB::table('plan_versions')->insert($clone);

    $this->get('/')
        ->assertOk()
        ->assertSee('Preise derzeit auf Anfrage')
        // No number survives from the broken catalogue.
        ->assertDontSee("79\u{00A0}€", false);

    Log::shouldHaveReceived('error')
        ->withArgs(fn (string $message) => str_contains($message, 'plan catalogue'))
        ->once();
});

it('names the currency the catalogue is priced in, not the one the page was written in', function () {
    // The currency is configurable. A sheet that prints "€" while the checkout
    // charges francs is the same two-sources-of-truth mistake as a hard-coded
    // amount — only harder to spot, because the number looks right.
    config()->set('provisioning.currency', 'CHF');
    DB::table('plan_prices')->update(['currency' => 'CHF']);

    $this->get('/')
        ->assertOk()
        ->assertSee("79\u{00A0}CHF", false)
        ->assertDontSee("79\u{00A0}€", false);
});

it('marks the plan the console recommends, and only that one', function () {
    DB::table('plan_families')->update(['is_recommended' => false]);
    DB::table('plan_families')->where('key', 'business')->update(['is_recommended' => true]);

    $content = $this->get('/')->assertOk()->getContent();

    expect(strpos($content, '>Business<'))->toBeInt()
        ->and(strpos($content, 'Empfohlen'))->toBeGreaterThan(strpos($content, '>Business<'))
        // Singular: exactly one plan carries the mark on the page.
        ->and(substr_count($content, 'Empfohlen'))->toBe(1);
});

it('shows the audience line the console saved, not a string compiled into the controller', function () {
    // The point of this migration: the console is the one place this
    // sentence is written, same as the price.
    DB::table('plan_families')
        ->where('key', 'team')
        ->update(['audience' => 'Für Redaktionen und Kanzleien']);

    $this->get('/')
        ->assertOk()
        ->assertSee('Für Redaktionen und Kanzleien')
        ->assertDontSee('Für wachsende Teams');
});

it('states what every package has once, above the table instead of as a row in it', function () {
    // Four rows of ticks running straight down every column, and then the same
    // promises again in a line under the table. Neither told a visitor anything
    // about the choice they were there to make.
    $content = $this->get('/')->assertOk()->getContent();

    expect($content)->toContain('In jedem Paket enthalten')
        ->and($content)->toContain('Überwachung rund um die Uhr')
        // That the table was found at all: every "is not in the table"
        // assertion in this file passes for free against an empty string.
        ->and(priceSheetTable($content))->toContain('Eigene Domain')
        ->and(priceSheetTable($content))->not->toContain('Überwachung rund um die Uhr')
        // The prose line that used to repeat it under the table is gone with it.
        ->and($content)->not->toContain('In jedem Paket:');
});

it('offers what a plan lacks at the price the module catalogue would charge', function () {
    // The third cell state, and the reason it exists: team does not carry an
    // own domain, but we sell one — a dash there was the page telling a visitor
    // no on a sale we would happily make.
    $this->get('/')->assertOk()->assertSee('optional · '.priceSheetGross(900), false);

    // And the figure follows the catalogue. Written into the template it would
    // be right until the first time somebody changed the price and read the
    // page as confirmation.
    config()->set('provisioning.addons.custom_domain.price_cents', 1900);

    $this->get('/')
        ->assertOk()
        ->assertSee('optional · '.priceSheetGross(1900), false)
        ->assertDontSee('optional · '.priceSheetGross(900), false);
});

it('states the own-domain rule package by package: impossible, optional, included', function () {
    // The owner's rule, on the page that has to say it: an own domain is
    // impossible on the entry package, a module on the one above it, and part
    // of the one above that. Quoting nine euros in the first column was the
    // sheet making an offer the shop would then refuse — and the price sheet is
    // where somebody decides which package to buy in the first place.
    $row = priceSheetRow($this->get('/')->assertOk()->getContent(), 'Eigene Domain');
    $cells = array_slice(explode('<td', $row), 1);

    // Drei Spalten, seit Enterprise angefragt statt gekauft wird.
    expect($cells)->toHaveCount(3)
        ->and($cells[0])->toContain('—')
        ->and($cells[0])->not->toContain('optional')
        ->and($cells[0])->not->toContain('€')
        ->and($cells[1])->toContain('optional · '.priceSheetGross(900))
        ->and($cells[2])->toContain('inklusive');
});

it('leaves a feature we do not sell as a dash, without inventing a price for it', function () {
    // Longer retention is not on the module list, so start does not get an
    // offer for it. Being able to say "no" is what makes the "optional" cells
    // above worth anything.
    $row = priceSheetRow($this->get('/')->assertOk()->getContent(), 'Verlängerte Aufbewahrung');

    expect($row)->toContain('—')
        ->and($row)->not->toContain('optional')
        ->and($row)->not->toContain('€');
});

it('shows one support level per plan, and never a dash for the best of them', function () {
    // priority_support and premium_sla are steps on one ladder, and as two
    // independent rows they made the top plan look worse than the middle one:
    // enterprise carries the SLA and not the priority flag, so it rendered
    // "Bevorzugter Support —" beside team's tick.
    //
    // Enterprise gehört hier auf die Seite, denn es ist das Paket, an dem der
    // Fehler entstand — die neue Leiter nimmt es aus dem Verkauf, und ohne
    // diese Zeile prüfte der Test die Stufe „Premium" gar nicht mehr. Beide
    // Schalter zurück auf "verkäuflich": `internal` hält es seit Aufgabe 11
    // zusätzlich aus sellable() heraus, unabhängig von `sales_enabled`.
    PlanFamily::query()->where('key', 'enterprise')->update(['sales_enabled' => true, 'internal' => false]);

    $content = $this->get('/')->assertOk()->getContent();
    $row = priceSheetRow($content, 'Support');

    expect($row)->toContain('Premium')
        ->and($row)->toContain('Standard')
        ->and($row)->not->toContain('—')
        // The two booleans are gone from the table as separate rows.
        ->and(priceSheetTable($content))->not->toContain('Vereinbarte Reaktionszeiten');
});

it('shows the platform address in the shape a customer will type it, under the configured zone', function () {
    // "Eigene CluPilot-Subdomain" as a table row was meaningless twice over:
    // every instance gets one, and the phrase does not say what it looks like.
    // Through the setting, not the config: the setting is what
    // ProvisioningSettings::dnsZone() reads first, and since instances moved to
    // their own zone there is a row in it on every installation.
    App\Support\Settings::set('provisioning.dns_zone', 'wolke.test');

    $this->get('/')
        ->assertOk()
        ->assertSee('ihrefirma.wolke.test')
        ->assertDontSee('ihrefirma.clupilot.com');
});

it('names the modules it sells, with the prices the catalogue charges for them', function () {
    // The complaint that started this: the shop sold five modules and the
    // public page mentioned none of them.
    $storage = (int) config('provisioning.storage_addon.gb');
    // Gross, because that is what the sheet quotes — a private customer must
    // never be shown a figure that grows at checkout.
    $money = fn (int $cents) => priceSheetGross($cents);

    $page = $this->get('/')->assertOk();

    $page->assertSee('Optional dazubuchbar');

    foreach (['extra_backups', 'priority_support', 'collabora_pro', 'custom_domain'] as $key) {
        $page->assertSee($money((int) config("provisioning.addons.{$key}.price_cents")), false);
    }

    // The storage pack is sold by the pack, so the sheet says how big one is.
    $page->assertSee($storage.' GB')
        ->assertSee($money((int) config('provisioning.storage_addon.price_cents')), false);
});

it('keeps the sheet up when nothing is on sale on top of a package', function () {
    // An installation that sells no modules is a configuration, not a failure.
    // The block disappears; the cells that could have been an offer go back to
    // being honest dashes.
    config()->set('provisioning.addons', []);
    config()->set('provisioning.storage_addon', []);

    $this->get('/')
        ->assertOk()
        ->assertDontSee('Optional dazubuchbar')
        ->assertDontSee('optional · ', false)
        // The comparison itself is untouched: an own domain still tells the
        // packages apart, it just cannot be bought into the smaller ones.
        ->assertSee('Eigene Domain');
});

it('drops a catalogue feature it has no wording for, rather than printing its key', function () {
    $version = PlanVersion::query()
        ->where('plan_family_id', PlanFamily::query()->where('key', 'team')->value('id'))
        ->firstOrFail();

    DB::table('plan_versions')->where('id', $version->id)->update([
        'features' => json_encode([...$version->features, 'quantum_entanglement']),
    ]);

    $this->get('/')
        ->assertOk()
        ->assertDontSee('quantum_entanglement');
});

it('leaves Enterprise in the comparison table while it is still on sale', function () {
    // Die Seite folgt dem Schalter und nicht einer Liste im Quelltext: wird
    // Enterprise wieder verkauft, steht es wieder in der Tabelle. Checked
    // against the CTA text rather than the (now interpolated) heading — a
    // heading built with no replacement never matches what the page actually
    // renders anyway, sellable or not, and would prove nothing either way.
    // Beide Schalter zurück auf "verkäuflich" (siehe Test oben).
    PlanFamily::query()->where('key', 'enterprise')->update(['sales_enabled' => true, 'internal' => false]);

    $content = $this->get('/')->assertOk()->getContent();

    expect(priceSheetTable($content))->toContain('>Enterprise<')
        ->and($content)->not->toContain(__('landing.enterprise_cta'));
});

it('shows Enterprise as a request for a quote once it is taken off sale', function () {
    // Der Zustand, den Aufgabe 11 gesetzt hat: `internal`, nicht
    // `sales_enabled = false` — Enterprise bleibt verschenkbar (grantable()),
    // nur eben nicht mehr im Preisblatt (sellable()).
    $content = $this->get('/')->assertOk()->getContent();

    expect(PlanFamily::query()->where('key', 'enterprise')->value('internal'))->toBeTruthy()
        ->and(PlanFamily::query()->where('key', 'enterprise')->value('sales_enabled'))->toBeTruthy();

    expect($content)
        // Business (175 GB auf der neuen Leiter) ist das größte Paket, das
        // übrig bleibt, wenn Enterprise aus $plans fällt.
        ->toContain(__('landing.enterprise_title', ['quota' => '175 GB']))
        ->and($content)->toContain(__('landing.enterprise_cta'))
        // No price is left to compare it against once it drops out of
        // PlanCatalogue::sellable() — not a dash in a row, no column at all.
        ->and(priceSheetTable($content))->not->toContain('>Enterprise<');
});

it('names the largest sellable quota in the heading, not a figure typed into the language file', function () {
    // The point of Change 2: a plan restructure moves what "the largest
    // package" holds, and the heading has to move with it. Two different
    // sizes in a row is the actual proof — a single value could just as
    // easily be the coincidence of matching a number already sitting there.
    $businessId = PlanFamily::query()->where('key', 'business')->value('id');

    // Both above Team's own quota, so Business stays the largest family
    // throughout — otherwise this would accidentally test Team's figure
    // showing through instead of proving Business's tracks its own change.
    DB::table('plan_versions')->where('plan_family_id', $businessId)->update(['quota_gb' => 555]);
    $first = $this->get('/')->assertOk()->getContent();

    DB::table('plan_versions')->where('plan_family_id', $businessId)->update(['quota_gb' => 777]);
    $second = $this->get('/')->assertOk()->getContent();

    expect($first)->toContain(__('landing.enterprise_title', ['quota' => '555 GB']))
        ->and($second)->toContain(__('landing.enterprise_title', ['quota' => '777 GB']))
        ->and($second)->not->toContain(__('landing.enterprise_title', ['quota' => '555 GB']));
});

it('keeps the front page up instead of naming a size when no package is sellable at all', function () {
    // The edge Change 2 introduced a way to break: max() on an empty list of
    // quotas throws. Mirrors the existing "catalogue cannot be read" test
    // above — two overlapping versions make PlanCatalogue::sellable() refuse
    // to guess, $plans comes back empty, and there is no "largest package"
    // left to name a heading against.
    Log::spy();

    $team = PlanFamily::query()->where('key', 'team')->firstOrFail();
    $original = $team->versionAt();

    $clone = collect($original->getAttributes())->except('id')->all();
    $clone['uuid'] = (string) Str::uuid();
    $clone['version'] = $original->version + 1;
    DB::table('plan_versions')->insert($clone);

    $this->get('/')
        ->assertOk()
        ->assertDontSee(__('landing.enterprise_cta'));
});
