<?php

namespace App\Http\Controllers;

use App\Models\Subscription;
use App\Services\Billing\AddonCatalogue;
use App\Services\Billing\CustomDomainAccess;
use App\Services\Billing\PlanCatalogue;
use App\Services\Billing\SetupFee;
use App\Services\Billing\TaxTreatment;
use App\Services\Provisioning\HostCapacity;
use App\Support\CompanyProfile;
use App\Support\ProvisioningSettings;
use Illuminate\Contracts\View\View;
use Illuminate\Support\Facades\Log;
use Throwable;

/**
 * The public price sheet.
 *
 * Prices, storage and seat counts are read from the catalogue rather than
 * written into the page. They were hard-coded here once, and the page and the
 * catalogue had already drifted apart on three of four plans — a visitor was
 * quoted 249 € for a plan that charged 399 € at checkout. The marketing site is
 * a reader of the catalogue like every other caller.
 *
 * The catalogue fails loudly by design: an empty or overlapping catalogue is an
 * outage for commerce, not something to paper over. But a public website is not
 * commerce — a mistyped availability window must not take the company's front
 * page down with it. So the failure is caught HERE and only here: the page
 * still renders, the price sheet is replaced by "on request", and nobody is
 * shown a number the checkout would not honour.
 *
 * The sheet is three blocks, not one matrix. A single grid of ticks was being
 * asked to carry three different kinds of sentence at once, and it made all
 * three unreadable: what every package has (a tick straight down every column,
 * which is no information at all), what tells the packages apart, and what
 * costs extra. Worse, two of the columns were lying by construction — support
 * was modelled as two independent booleans, so the top plan rendered a dash
 * where the middle one had a tick, and the platform address was modelled as if
 * an own domain replaced it, when provisioning issues both. Each block now
 * makes one kind of statement: BASELINE what everyone gets, the comparison
 * table what differs, ADDONS what is for sale on top.
 *
 * A fourth block, ENTERPRISE, sits beside the comparison table rather than
 * inside it: once a family drops out of PlanCatalogue::sellable() it has no
 * price left to compare, and a dash would say "not included" about something
 * that is not for sale at any figure. See enterprise() for when it shows.
 */
class LandingController extends Controller
{
    /**
     * Catalogue feature keys, in the words a customer uses.
     *
     * Deliberately separate from the operator-facing labels in
     * lang/*\/billing.php's `feature` array, which name the same keys for the
     * console's checkboxes. The two are different registers for different
     * readers — "Protokollierung der Zugriffe" here versus "Audit-Log" there —
     * and features are catalogue-wide, not per-plan, so there is nowhere on a
     * plan family or version to hang an editable customer-facing label
     * without re-introducing a shared feature table. That table
     * (`plan_features`) existed once and was deliberately removed in the
     * catalogue rebuild that closed the pricing split-brain; bringing it back
     * for wording alone is a bigger, separate decision.
     */
    private const FEATURES = [
        'managed_updates' => 'Updates & Wartung',
        'daily_backups' => 'Tägliche Sicherung',
        'monitoring' => 'Überwachung rund um die Uhr',
        // Filled in from the configured zone by zone(), reached through
        // label(), because a hard-coded domain here is the same
        // two-sources-of-truth mistake as a hard-coded price — and it shipped
        // naming clupilot.cloud, a domain the company does not own, while
        // provisioning was issuing addresses under whatever CLUPILOT_DNS_ZONE
        // said.
        'subdomain' => 'Adresse auf :zone',
        'custom_domain' => 'Eigene Domain',
        'office' => 'Office im Browser',
        'branding' => 'Ihr Logo & Ihre Farben',
        'priority_support' => 'Bevorzugter Support',
        'premium_sla' => 'Vereinbarte Reaktionszeiten',
        'extended_retention' => 'Verlängerte Aufbewahrung',
        'audit_log' => 'Protokollierung der Zugriffe',
        'onboarding' => 'Begleitete Einführung',
    ];

    /**
     * What every package carries, said once above the table.
     *
     * Each of these was a row of ticks running straight down all four columns
     * — a comparison that compares nothing — and the same promises were then
     * repeated in a prose line under the table. `key` names the catalogue
     * feature the claim stands on: it keeps the row out of the comparison
     * table, and, unless the claim is `issued`, the whole item is dropped
     * should a plan ever stop carrying it. The page must not promise for
     * "jedes Paket" what one package does not have.
     *
     * The last two have no key because the catalogue does not model them. They
     * are the promises the prose line under the table made, kept word for word
     * rather than reworded on the way in.
     */
    private const BASELINE = [
        [
            'icon' => 'refresh',
            'label' => 'Updates & Wartung',
            'detail' => 'Betriebssystem und Anwendung, eingespielt außerhalb Ihrer Arbeitszeit.',
            'key' => 'managed_updates',
        ],
        [
            'icon' => 'database',
            'label' => 'Tägliche verschlüsselte Sicherung',
            'detail' => 'Mit monatlichem Wiederherstellungstest, protokolliert.',
            'key' => 'daily_backups',
        ],
        [
            'icon' => 'activity',
            'label' => 'Überwachung rund um die Uhr',
            'detail' => 'Erreichbarkeit, Speicher und Dienste — Alarm, bevor Sie es merken.',
            'key' => 'monitoring',
        ],
        [
            'icon' => 'globe',
            'label' => 'Ihre Adresse ab der ersten Minute',
            // The shape, not the word for it. "Eigene Subdomain" tells a
            // customer nothing; the address they will actually type does.
            'detail' => 'ihrefirma.:zone',
            'mono' => true,
            // Issued, not sold: ConfigureDnsAndTls gives every instance its
            // platform address whatever `subdomain` says on the plan version
            // that was frozen years ago — business and enterprise do not carry
            // the flag and get the address anyway. The key is named so the row
            // leaves the comparison table; the promise does not hang on it.
            'key' => 'subdomain',
            'issued' => true,
        ],
        [
            'icon' => 'file-text',
            'label' => 'AV-Vertrag & TOM-Dokumentation',
            'detail' => 'Schriftlich, für Ihre eigene Dokumentation und Prüfung.',
        ],
        [
            'icon' => 'life-buoy',
            'label' => 'Persönlicher Support',
            'detail' => 'Antwort am selben Werktag, ohne Warteschleife.',
        ],
    ];

    /**
     * The support level a plan carries, strongest first.
     *
     * One axis, not two. `priority_support` and `premium_sla` are steps on the
     * same ladder, and as independent tick boxes 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 —" while team
     * rendered a tick. No plan carrying either is not an absence — standard
     * support is a real level, and the baseline block above says what it is.
     */
    private const SUPPORT = [
        'premium_sla' => ['level' => 'Premium', 'note' => 'mit zugesagter Reaktionszeit'],
        'priority_support' => ['level' => 'Bevorzugt', 'note' => 'Anfragen werden vorgezogen'],
    ];

    private const SUPPORT_STANDARD = ['level' => 'Standard', 'note' => 'Antwort am selben Werktag'];

    /**
     * The modules we sell on top of a package, in the words a customer uses.
     *
     * Same split as FEATURES and for the same reason: lang/*\/billing.php names
     * these for the portal's booking cards ("Collabora Online Pro"), which is
     * the register of somebody who already bought. A visitor deciding has not
     * heard of Collabora. The prices are never written here — they come from
     * AddonCatalogue, so the sheet and the booking page cannot disagree.
     */
    private const ADDONS = [
        'extra_backups' => [
            'name' => 'Zweiter Sicherungsort',
            'body' => 'Eine zusätzliche verschlüsselte Kopie Ihrer Daten, getrennt vom ersten Ort gelagert.',
        ],
        'priority_support' => [
            'name' => 'Bevorzugter Support',
            'body' => 'Ihre Anfragen werden vorgezogen — Reaktion innerhalb einer Stunde statt am selben Werktag.',
        ],
        'collabora_pro' => [
            'name' => 'Office mit vollem Funktionsumfang',
            'body' => 'Mehr gleichzeitige Bearbeiter und die erweiterten Funktionen der Office-Integration.',
        ],
        'custom_domain' => [
            'name' => 'Eigene Domain',
            'body' => 'Ihre Cloud unter Ihrer eigenen Adresse, samt Zertifikat und Einrichtung.',
        ],
        AddonCatalogue::STORAGE => [
            'name' => 'Zusatzspeicher',
            'body' => 'Ein Paket mit :size — jederzeit ergänzbar, ohne Umzug Ihrer Daten.',
        ],
    ];

    public function __invoke(): View
    {
        $plans = $this->plans();

        // Read once and handed to both blocks that quote a module price, so the
        // "optional" cell in the table and the module list underneath it cannot
        // print two different figures for the same thing.
        $modules = $this->modulePrices();

        $tax = CompanyProfile::taxRate();
        // Through SetupFee, which is also what the checkout charges. The sentence
        // on this page named a figure that nothing collected for months; now the
        // quote and the charge come out of one place, so a change to either the fee
        // or the rate cannot move one without moving the other.
        $setupNet = SetupFee::netCents();

        return view('landing', [
            'plans' => $plans,
            // What the page has to say about tax and about the one-off fee, in
            // one place. Both are console-editable figures, and both were
            // previously sentences on the page: "Netto pro Monat" and "zzgl.
            // einmaliger Einrichtung" — the second of which never named a
            // number at all, so a visitor learned only that there was one.
            'vat' => [
                'rate' => $tax,
                'label' => rtrim(rtrim(number_format($tax, 1, ',', '.'), '0'), ','),
            ],
            'setup' => $setupNet === 0 ? null : [
                'gross' => $this->money(SetupFee::chargedCents(TaxTreatment::domestic()), Subscription::catalogueCurrency()),
                'net' => $this->money($setupNet, Subscription::catalogueCurrency()),
            ],
            'baseline' => $this->baseline($plans),
            'comparison' => $this->comparison($plans, $modules),
            'addons' => $this->addons($modules),
            'enterprise' => $this->enterprise($plans),
        ]);
    }

    /**
     * The promises that hold for every package on sale.
     *
     * @param  array<int, array<string, mixed>>  $plans
     * @return array<int, array<string, mixed>>
     */
    private function baseline(array $plans): array
    {
        return array_values(array_filter(
            array_map(fn (array $item) => [...$item, 'detail' => $this->zone($item['detail'])], self::BASELINE),
            function (array $item) use ($plans) {
                $key = $item['key'] ?? null;

                if ($key === null || ($item['issued'] ?? false)) {
                    return true;
                }

                // "In jedem Paket" is a claim about all of them, so one plan
                // without the feature retires the claim rather than weakening
                // it to a footnote nobody reads.
                foreach ($plans as $plan) {
                    if (! in_array($key, $plan['keys'], true)) {
                        return false;
                    }
                }

                return true;
            },
        ));
    }

    /**
     * The rows on which the packages actually differ, each cell in one of three
     * states: carried, buyable, or neither.
     *
     * Two states could not tell "you do not get this" apart from "you can have
     * it for nine euros", so the sheet said the first about four things we
     * would happily have sold — which is how the page came to mention none of
     * the modules at all.
     *
     * Built from what is on sale rather than from the full feature list, so a
     * feature no current plan offers does not appear as an empty row.
     *
     * @param  array<int, array<string, mixed>>  $plans
     * @param  array<string, int>  $modules
     * @return array<int, array{label: string, cells: array<string, array<string, mixed>>}>
     */
    private function comparison(array $plans, array $modules): array
    {
        $baselineKeys = array_column(self::BASELINE, 'key');
        $rows = [];
        $supportPlaced = false;

        foreach (array_keys(self::FEATURES) as $key) {
            if (in_array($key, $baselineKeys, true)) {
                continue;
            }

            // The support row takes the place of the first of the two keys it
            // replaces, so the sheet keeps the catalogue's narrative order
            // rather than growing an appendix at the bottom.
            if (isset(self::SUPPORT[$key])) {
                $row = $supportPlaced ? null : $this->supportRow($plans, $modules);
                $supportPlaced = true;

                if ($row !== null) {
                    $rows[] = $row;
                }

                continue;
            }

            $offered = array_filter($plans, fn (array $plan) => in_array($key, $plan['keys'], true));

            if ($offered === []) {
                continue;
            }

            $cells = [];

            foreach ($plans as $plan) {
                $cells[$plan['key']] = match (true) {
                    in_array($key, $plan['keys'], true) => ['state' => 'included'],
                    // Not carried, but on sale TO THIS PACKAGE — the only
                    // honest third answer. A module we do not sell stays a
                    // dash, however easy it would be to invent a price for it,
                    // and so does one this package may not book: an own domain
                    // is impossible on the entry package, so quoting nine euros
                    // there would be selling something we would then refuse.
                    isset($modules[$key]) && $this->bookableOn($key, $plan) => ['state' => 'optional', 'price' => $this->modulePrice($modules[$key])],
                    default => ['state' => 'absent'],
                };
            }

            $rows[] = ['label' => (string) $this->label($key), 'cells' => $cells];
        }

        return $rows;
    }

    /**
     * Whether a package could actually book this module.
     *
     * Only the own domain has such a rule today, and the rule is not restated
     * here: CustomDomainAccess owns it, and the sheet asks. A page that decided
     * for itself which packages may buy a domain would be a second answer to a
     * question the shop already answers, and the two would part company the
     * first time the owner changed one of them.
     *
     * @param  array<string, mixed>  $plan
     */
    private function bookableOn(string $key, array $plan): bool
    {
        if ($key !== CustomDomainAccess::ADDON) {
            return true;
        }

        return app(CustomDomainAccess::class)->bookableOnPlan((string) $plan['key'], $plan['keys']);
    }

    /**
     * Support as one row of levels.
     *
     * Omitted entirely when no plan carries either key: four columns all
     * reading "Standard" is the row of identical ticks this rebuild removed,
     * and the baseline block has already said what standard support is.
     *
     * @param  array<int, array<string, mixed>>  $plans
     * @param  array<string, int>  $modules
     * @return array{label: string, cells: array<string, array<string, mixed>>}|null
     */
    private function supportRow(array $plans, array $modules): ?array
    {
        $cells = [];
        $differs = false;

        foreach ($plans as $plan) {
            $carried = self::SUPPORT_STANDARD;

            // Strongest first: a plan carrying both is shown at the level it
            // actually answers at, not at whichever key was checked first.
            foreach (self::SUPPORT as $key => $level) {
                if (in_array($key, $plan['keys'], true)) {
                    $carried = $level;
                    $differs = true;

                    break;
                }
            }

            $cells[$plan['key']] = [
                'state' => 'level',
                'text' => $carried['level'],
                // The upgrade displaces the level's own qualifier: a customer
                // reading the standard cell is better served by what the next
                // step costs than by a restatement of the line above.
                'note' => $carried === self::SUPPORT_STANDARD && isset($modules['priority_support'])
                    ? 'bevorzugt ab '.$this->modulePrice($modules['priority_support'])
                    : $carried['note'],
            ];
        }

        return $differs ? ['label' => 'Support', 'cells' => $cells] : null;
    }

    /**
     * Today's price for every module actually on sale, in cents.
     *
     * Swallowed like the plan catalogue and for the same reason (see the class
     * docblock): an empty or unreadable module list costs the page a block, not
     * the page. Every caller then reads a plain array, so a module that is not
     * in it is simply not for sale — which is exactly what the dash in the
     * table means.
     *
     * @return array<string, int>
     */
    private function modulePrices(): array
    {
        try {
            $catalogue = app(AddonCatalogue::class);
            $keys = [...array_keys((array) config('provisioning.addons', [])), AddonCatalogue::STORAGE];
            $prices = [];

            foreach ($keys as $key) {
                $cents = $catalogue->priceCents((string) $key);

                // Nothing is sold for nothing. An entry without a price reads
                // back as 0 cents, and "optional · 0 €" on a public price sheet
                // is an offer somebody would hold us to.
                if ($cents === null || $cents <= 0) {
                    continue;
                }

                // The storage pack is sold by the pack, so a pack of no size is
                // not a product either.
                if ($key === AddonCatalogue::STORAGE && (int) config('provisioning.storage_addon.gb', 0) <= 0) {
                    continue;
                }

                $prices[(string) $key] = $cents;
            }

            return $prices;
        } catch (Throwable $e) {
            Log::error('Landing page could not read the add-on catalogue', ['exception' => $e]);

            return [];
        }
    }

    /**
     * The modules, in the order the catalogue lists them, with today's prices.
     *
     * @param  array<string, int>  $modules
     * @return array<int, array{name: string, body: string, price: string}>
     */
    private function addons(array $modules): array
    {
        $rows = [];

        foreach ($modules as $key => $cents) {
            $copy = self::ADDONS[$key] ?? null;

            // Same rule as an unknown feature key: a module nobody has written
            // up is left out rather than advertised as "collabora_pro".
            if ($copy === null) {
                continue;
            }

            $rows[] = [
                'name' => $copy['name'],
                'body' => str_replace(
                    ':size',
                    $this->storage((int) config('provisioning.storage_addon.gb', 0)),
                    $copy['body'],
                ),
                'price' => $this->modulePrice($cents),
            ];
        }

        return $rows;
    }

    /**
     * The request-a-quote block that replaces Enterprise's column once the
     * family is no longer a priced catalogue entry.
     *
     * Null in two cases. While Enterprise is still in `$plans` — today's
     * state, and the one every install starts in — the sheet renders exactly
     * as before this method existed. And when `$plans` itself is empty: that
     * is the catalogue-read-failed state the class docblock already covers,
     * the sheet already says "Preise derzeit auf Anfrage" for it, and there
     * is no "largest sellable package" to measure the heading against —
     * inventing a size here (or worse, `max()` on an empty array) would be
     * the same lie in a new place, not a fix for the empty one.
     *
     * Read off `$plans` rather than queried again from PlanFamily or
     * PlanVersion: `$plans` already IS PlanCatalogue::sellable()'s answer for
     * this request, and a second "how big is the largest package" question
     * here could drift from it the moment either changed — the same
     * split-brain this class exists to avoid.
     *
     * The heading names the largest sellable quota rather than a number typed
     * into the language file, for the same reason: a plan restructure changes
     * what the largest package holds (the pricing rebuild this task is part
     * of does exactly that to Business), and a fixed figure would go on
     * quietly naming the wrong ceiling with nothing to catch it. There is
     * deliberately no SECOND, lower threshold ("ab 500 GB") in the copy any
     * more either — the dedicated machine starts exactly where the largest
     * sellable package ends; how big any particular one becomes is a
     * hardware question answered per quote, not a second number on the price
     * sheet.
     *
     * The rest of the copy is fixed rather than pulled from the catalogue:
     * there is no price to quote for a machine that is not built yet, and
     * Enterprise cannot simply become a normal catalogue row again either —
     * placeableIn() finds no host on the 388 GB a shared machine actually has
     * to give away, so a checkout would take the payment and only then
     * discover it cannot deliver.
     *
     * @param  array<int, array<string, mixed>>  $plans
     * @return array{title: string, body: string, cta: string, href: string}|null
     */
    private function enterprise(array $plans): ?array
    {
        if ($plans === [] || in_array('enterprise', array_column($plans, 'key'), true)) {
            return null;
        }

        return [
            'title' => __('landing.enterprise_title', [
                'quota' => $this->storage((int) max(array_column($plans, 'quota_gb'))),
            ]),
            'body' => __('landing.enterprise_body'),
            'cta' => __('landing.enterprise_cta'),
            // Bis hierher stand hier `mailto:office@clupilot.com`, mit der
            // Begründung, es gebe keine Kontaktseite und eine anzulegen sei zu
            // viel für einen Knopf. Der Knopf tat damit auf den meisten Geräten
            // sichtbar nichts — ohne eingerichtetes Mailprogramm passiert
            // schlicht gar nichts. Ausgerechnet an der Stelle, an der das
            // Selbstbedienen aufhört und jemand von sich aus fragen will.
            //
            // Mit `thema=enterprise`, damit die Seite weiß, wonach gefragt wird:
            // sie stellt dann die Frage nach Speicher und Personenzahl, statt
            // ein leeres Feld hinzustellen.
            'href' => route('contact', ['thema' => 'enterprise']),
        ];
    }

    /**
     * @return array<int, array<string, mixed>> empty when the catalogue cannot be read
     */
    private function plans(): array
    {
        try {
            $sellable = app(PlanCatalogue::class)->sellable();
        } catch (Throwable $e) {
            // Deliberately swallowed: see the class docblock. Logged at error
            // level because a shop that cannot list its plans is not selling.
            Log::error('Landing page could not read the plan catalogue', ['exception' => $e]);

            return [];
        }

        $plans = [];
        $capacity = app(HostCapacity::class);

        foreach ($sellable as $key => $plan) {
            $plans[] = [
                'key' => $key,
                'name' => $plan['name'],
                // How soon this package can actually be delivered, read from
                // the estate rather than from a sentence somebody typed. Where
                // a host has room it rolls out on its own within the hour;
                // where none has, a machine has to be bought first — and
                // saying "sofort" in that case is a promise the very next
                // customer catches us breaking.
                'delivery' => $capacity->deliveryFor((int) ($plan['disk_gb'] ?? 0)),
                // Console-edited, from the family the plan belongs to — a
                // family that has none yet (created but not written up) gets
                // an empty string rather than a missing array key, and the
                // template renders that sensibly instead of an empty gap.
                'audience' => (string) ($plan['audience'] ?? ''),
                'note' => (string) ($plan['note'] ?? ''),
                // GROSS, because a private customer must never be quoted a
                // figure that grows at checkout. A business reads the net one
                // underneath and gets the VAT back anyway; a consumer does not,
                // and for them the net figure was simply the wrong number.
                'price' => $this->money($this->gross((int) $plan['price_cents']), (string) $plan['currency']),
                'price_net' => $this->money((int) $plan['price_cents'], (string) $plan['currency']),
                // Paying for a year, in the same three figures the order page
                // shows: what leaves the account once, what that works out to
                // per month, and what twelve monthly payments would have been.
                // A sheet that quotes only the monthly price cannot mention a
                // saving, and the saving is the reason to offer the term.
                'price_yearly' => $this->money(
                    $yearlyGross = $this->gross((int) ($plan['yearly_price_cents'] ?? 0)),
                    (string) $plan['currency'],
                ),
                'price_yearly_net' => $this->money((int) ($plan['yearly_price_cents'] ?? 0), (string) $plan['currency']),
                // intdiv, so the headline never claims a tenth of a cent nobody
                // is charged; the total beside it is the amount actually taken.
                'price_yearly_monthly' => $this->money(intdiv($yearlyGross, 12), (string) $plan['currency']),
                'price_twelve_months' => $this->money($this->gross((int) $plan['price_cents']) * 12, (string) $plan['currency']),
                'free_months' => (int) ($plan['free_months'] ?? 0),
                'storage' => $this->storage((int) $plan['quota_gb']),
                // The raw figure beside its formatted twin, same reason as
                // 'keys' beside 'features': enterprise() has to find the
                // largest sellable quota to name in its heading, and a second
                // catalogue read to get an unformatted number back out of a
                // "500 GB" string would be worse than carrying the int along.
                'quota_gb' => (int) $plan['quota_gb'],
                'traffic' => $this->storage((int) $plan['traffic_gb']),
                'seats' => (int) $plan['seats'],
                'features' => $this->features($plan['features'] ?? []),
                // The raw keys alongside the wording. Everything that has to
                // decide something — which row this plan fills, which support
                // level it answers at — asks the key; only the card bullets
                // read the words. Deciding on the words matched until the
                // first label gained a placeholder, and then quietly stopped.
                'keys' => array_values((array) ($plan['features'] ?? [])),
                'recommended' => (bool) ($plan['recommended'] ?? false),
            ];
        }

        return $plans;
    }

    /**
     * One feature, in the words a customer uses.
     *
     * The address the platform hands out is a setting, not a constant — a
     * server can be installed against any zone — so the sentence naming it is
     * assembled rather than written down twice.
     */
    private function label(string $key): ?string
    {
        $label = self::FEATURES[$key] ?? null;

        return $label === null ? null : $this->zone($label);
    }

    /** The configured zone, wherever a sentence names it. */
    private function zone(string $text): string
    {
        return str_replace(':zone', ProvisioningSettings::dnsZone(), $text);
    }

    /** Currencies we have a symbol for; anything else prints its ISO code. */
    private const SYMBOLS = ['EUR' => '€', 'CHF' => 'CHF', 'USD' => '$', 'GBP' => '£'];

    /**
     * A price as the sheet prints it, currency included.
     *
     * The symbol comes from the catalogue rather than from the template: the
     * currency is configurable (CLUPILOT_CURRENCY), and a page that says "€"
     * while the checkout charges francs is the same two-sources-of-truth
     * mistake as a hard-coded amount, only harder to notice.
     *
     * Whole units when the amount is whole — a price sheet reads "179", not
     * "179,00".
     */
    /**
     * A net figure as the ordinary customer is charged it.
     *
     * Handed straight to TaxTreatment rather than worked out here. It is the same
     * call the domestic Stripe Price is formed with, so the figure on this page and
     * the figure on the card statement cannot drift apart. It used to be a
     * multiplication of its own, which is a second source of the same number and
     * therefore a second answer waiting to happen.
     *
     * This page is read by visitors nobody has met, so the domestic gross is the
     * only honest figure to put on it. A business in another member state with a
     * verified VAT id is charged less — the bare net, no VAT owed to us at all —
     * and learns that on the pages that know who they are, not here, where
     * advertising it would quote every private visitor a price they cannot have.
     */
    private function gross(int $netCents): int
    {
        return TaxTreatment::advertisedCents($netCents);
    }

    private function money(int $cents, string $currency): string
    {
        $amount = $cents % 100 === 0
            ? number_format($cents / 100, 0, ',', '.')
            : number_format($cents / 100, 2, ',', '.');

        // Non-breaking: in a narrow table column "49 €" otherwise wraps, and the
        // currency ends up on a line of its own under the number.
        return $amount."\u{00A0}".(self::SYMBOLS[strtoupper($currency)] ?? strtoupper($currency));
    }

    /**
     * A module price.
     *
     * A plan price carries its own currency from the catalogue; a module does
     * not, and cannot — config holds one net amount. The one currency the
     * catalogue is priced in is therefore the honest answer, and it is the same
     * one the booking page charges in.
     */
    private function modulePrice(int $cents): string
    {
        // Gross, like every other figure on this page. A sheet that quotes the
        // package with VAT and the module without it is a sheet where the two
        // numbers cannot be added up.
        return $this->money($this->gross($cents), Subscription::catalogueCurrency());
    }

    private function storage(int $gb): string
    {
        return $gb >= 1000 && $gb % 1000 === 0
            ? ($gb / 1000).' TB'
            : $gb.' GB';
    }

    /**
     * @param  array<int, string>  $keys
     * @return array<int, string>
     */
    private function features(array $keys): array
    {
        // Unknown keys are dropped rather than printed raw: a feature added to
        // the catalogue without a translation would otherwise appear on the
        // public page as "premium_sla".
        return array_values(array_filter(array_map(
            fn (string $key) => $this->label($key),
            $keys,
        )));
    }
}
