<?php

namespace App\Console\Commands;

use App\Models\PlanFamily;
use App\Models\PlanPrice;
use App\Models\PlanVersion;
use App\Models\Subscription;
use App\Services\Billing\AddonCatalogue;
use App\Services\Billing\AddonPrices;
use App\Services\Billing\AdoptStripePrice;
use App\Services\Billing\AdoptStripeProduct;
use App\Services\Billing\PlanPrices;
use App\Services\Billing\TaxTreatment;
use App\Services\Stripe\StripeClient;
use App\Support\OperatingMode;
use App\Support\StripeCatalogueMode;
use Illuminate\Console\Command;

/**
 * Mirrors our catalogue into Stripe: a Product per plan family, a Price per
 * priced row — and the same for every module we sell.
 *
 * Stripe owns the recurring billing — retries, dunning, off-session SCA,
 * invoice numbering — so it needs to know what it is billing for. It does not
 * need to know how big the VM is, and it is not asked.
 *
 * **It pushes TWO Prices for everything it sells.** The catalogue's `amount_cents`
 * is net and stays net — it is frozen onto every contract and PlanChange prorates
 * against it — and what Stripe is asked to take from it depends on who is buying:
 *
 *  - the DOMESTIC GROSS, which is the figure on the website and what everybody
 *    who is charged VAT pays, private and business alike. Pushing the net was the
 *    original defect: a customer quoted 214,80 € paid 179,00 € and was then
 *    invoiced 179,00 € plus 35,80 € VAT that nobody had collected;
 *  - the BARE NET, for a business in another member state whose VAT id is
 *    verified. Reverse charge means no VAT is owed to us at all, so the net is
 *    the whole of what they owe. Charging them the gross was a flat 20 % surcharge
 *    with no VAT line on the document for them to reclaim.
 *
 * Which of the two a checkout uses is TaxTreatment's decision — see
 * App\Services\Billing\PlanPrices, which owns the plan half of the mirror, and
 * App\Services\Billing\AddonPrices, which owns the module half.
 *
 * Stripe's `automatic_tax` is deliberately NOT used. TaxTreatment is the single
 * tax authority here; a second rate computed by Stripe would charge a German
 * consumer 19 % while our document said 20 %.
 *
 * Idempotent by construction, and safe to re-run after a rate change: a row
 * whose live Price already charges the right figure is skipped, and one that
 * does not gets a NEW Price with the old one archived. A rate change moves the
 * gross Price of each pair and leaves the net one untouched, because the net does
 * not depend on the rate. That matters more here than usual, because a Stripe
 * Price cannot be edited, so a run that minted duplicates would leave two live
 * prices for one plan at one figure and no way to tell which a customer is on.
 *
 * It does not move anybody. Archiving a Price stops it being SOLD and leaves
 * every subscription already on it exactly where it was — moving those is
 * stripe:reprice-subscriptions, which is a separate decision because it touches
 * live contracts.
 *
 * Only PUBLISHED versions are synced. A draft has promised nothing, and a
 * Product for it would be a price list entry for something that may never
 * exist.
 *
 * Nor is an INTERNAL family (`plan_families.internal`) — handed out by hand,
 * never sold, never billed. A Stripe Price cannot be deleted once minted, so a
 * package nobody is ever charged for stays out of the mirror entirely, on a
 * dry run and for real. A family that already carries Stripe ids from before
 * it was marked internal keeps them; this only stops anything new being added.
 *
 * **A run belongs to ONE Stripe account, and it says which.** Products and
 * Prices are account-bound, and the operating mode picks the key — so the mode
 * a run worked in is recorded (App\Support\StripeCatalogueMode) and a run into
 * a catalogue that belongs to the other account is refused rather than allowed
 * to skip its way to "already in step". Without that, switching to live left
 * every stored id pointing at test objects while the readiness page went on
 * reporting a synced catalogue, and the first real order met "No such price".
 *
 * **Modules were missing from this entirely**, which is why a booked module was
 * charged in the month it was booked and never again: nothing in Stripe existed
 * to put on the subscription. They are pushed at today's catalogue price on both
 * terms — a Stripe Price carries its own interval, so a module on a yearly
 * contract needs a yearly one. What this run cannot cover is a customer
 * grandfathered on an older figure or holding a discounted grant; those mint
 * their own Price at the moment they are billed. See App\Services\Billing\AddonPrices.
 */
class SyncStripeCatalogue extends Command
{
    protected $signature = 'stripe:sync-catalogue {--dry-run : Show what would be created without touching Stripe}';

    protected $description = 'Create the Stripe products and prices for the plan catalogue';

    public function handle(StripeClient $stripe): int
    {
        $dryRun = (bool) $this->option('dry-run');

        if (! $dryRun && ! $stripe->isConfigured()) {
            $this->error('Stripe is not configured (STRIPE_SECRET is empty). Nothing was created.');

            return self::FAILURE;
        }

        if (StripeCatalogueMode::hasStoredObjects() && StripeCatalogueMode::belongsToAnotherMode()) {
            $this->error($this->staleCatalogueMessage());

            // Auch der Trockenlauf endet hier, und mit FAILURE: er zählt, was
            // FEHLT, und in diesem Zustand fehlt nichts — jede Zeile trägt
            // schon eine ID, nur eine aus dem falschen Konto. „0 object(s)
            // would be created" wäre die beruhigende Antwort auf die
            // gefährliche Lage.
            return self::FAILURE;
        }

        // VOR der Anlegeschleife, nicht danach. Hier ist bereits bewiesen, dass
        // entweder nichts liegt oder das Vorhandene dem aktiven Konto gehört —
        // der Zeitpunkt trägt die Aussage also genauso gut. Am Ende von
        // handle() trug er sie NICHT: createProduct() und ensure() werfen
        // ungefangen, und ein Lauf, der nach dem ersten angelegten Objekt
        // stirbt, hinterließ IDs ohne Herkunft. Der nächste Lauf hielt das für
        // ein fremdes Konto und trug dem Betreiber auf, einen Katalog zu leeren,
        // an dem laufende Verträge abgerechnet werden — statt schlicht
        // wiederaufzusetzen, wofür die Idempotenzschlüssel unten gebaut sind.
        if (! $dryRun) {
            if (StripeCatalogueMode::recorded() === null && StripeCatalogueMode::hasStoredObjects()) {
                $this->line('  note     taking up a catalogue whose account was never recorded; nothing is replaced.');
            }

            StripeCatalogueMode::record();
        }

        // Beide Seiten setzen hier an, und beide werden gebraucht.
        // Die Modus-Sperre oben steht zuerst, weil sie VERWEIGERT: sie darf
        // nicht hinter etwas laufen, das schon Zustand aufgebaut hat. Die
        // Zähler darunter müssen nur vor der Anlegeschleife stehen, und das
        // tun sie hier auch.
        // Resolved once, as singletons: each counts across the WHOLE run, and a
        // count taken before ensure()/the adoption call runs can never itself
        // distinguish "created" from "adopted" — see AdoptStripePrice.
        $adoptPrices = app(AdoptStripePrice::class);
        $adoptProducts = app(AdoptStripeProduct::class);
        $adoptedPricesBefore = $adoptPrices->adoptions;
        // Duplicates accumulate on the SAME singleton across the whole process,
        // not only this run — without this, a second handle() in one process
        // would print a duplicate a previous run already reported.
        $duplicatesBefore = count($adoptProducts->duplicates);

        $created = 0;
        // Counted here, locally, rather than on AdoptStripeProduct itself: the
        // very same class also adopts a MODULE's Product, from
        // AddonPrices::product() inside syncModules() below, and a module's
        // Product is never counted as an intent at all — unlike a family's,
        // which gets its own "product …" line and $created++ right where this
        // is incremented. A single counter on the class could not tell the two
        // apart; counting only at the one call site that also counts the
        // intent avoids the question entirely.
        $adoptedFamilyProducts = 0;

        foreach (PlanFamily::query()->with('versions.prices')->orderBy('tier')->get() as $family) {
            // An internal family is never billed through Stripe — handed out by
            // hand (the internal test package, Enterprise once a machine is
            // built for it) and never bought. A Stripe Price cannot be deleted,
            // only archived, so whatever a live run mints here stays in the
            // operator's account for good — and a package nobody is ever
            // charged for is not worth that. Skipped here, ahead of the
            // published-version check below and everything after it, so a
            // family that already carries Stripe ids from when it WAS sellable
            // (Enterprise, precisely) is left untouched: nothing is cleared,
            // nothing is replaced, and nothing new is ever added for it.
            if ($family->internal) {
                continue;
            }

            $published = $family->versions->filter(fn (PlanVersion $version) => $version->isPublished());

            // A family whose versions are all drafts has promised nothing, so
            // it has no business appearing in Stripe's price list yet.
            if ($published->isEmpty()) {
                continue;
            }

            $productId = $family->stripe_product_id;

            if ($productId === null) {
                $this->line("  product  {$family->key} — {$family->name}");
                $created++;

                if (! $dryRun) {
                    $metadata = ['plan_family' => $family->key, 'plan_family_id' => (string) $family->id];

                    // Asked BEFORE minting, for the same reason the Price side
                    // asks: a run that died between Stripe's create and our
                    // update left a Product this row does not know, and the key
                    // below stops protecting it after twenty-four hours.
                    // plan_family_id is what proves such a Product is this
                    // family's.
                    $productId = $adoptProducts($metadata, ['plan_family_id']);

                    if ($productId !== null) {
                        // The intent was counted three lines up, so — unlike a
                        // module's Product — an adoption here may honestly be
                        // subtracted back out of it below.
                        $adoptedFamilyProducts++;
                    }

                    $productId ??= $stripe->createProduct(
                        $family->name,
                        $metadata,
                        // Covers a RETRY, for twenty-four hours, and nothing
                        // beyond them. What stops a second Product for one
                        // family is the adoption step above.
                        idempotencyKey: "clupilot-product-{$family->id}",
                    );

                    $family->update(['stripe_product_id' => $productId]);
                }
            }

            foreach ($published as $version) {
                foreach ($version->prices as $price) {
                    $created += $this->syncPrice($family, $version, $price, $productId, $dryRun);
                }
            }
        }

        $created += $this->syncModules($dryRun);

        $this->newLine();

        // $adopted may only subtract what $created actually counted as an
        // intent. Every price is one, family or module — syncPrice() and
        // syncModules() both increment $created before knowing whether ensure()
        // will mint or adopt, so the full price-adoption delta is honest. A
        // family's Product is one too, counted above, so $adoptedFamilyProducts
        // is honest for the same reason. A module's Product is NOT one — it
        // prints no line of its own and increments nothing in syncModules(),
        // however AdoptStripeProduct answers it — so its adoption stays out of
        // both figures entirely. Subtracting it would report fewer objects
        // created than Stripe actually gained.
        $adopted = ($adoptPrices->adoptions - $adoptedPricesBefore) + $adoptedFamilyProducts;
        $minted = max(0, $created - $adopted);

        // Sliced to what THIS run added — the list lives on the same
        // process-wide singleton as the count above, so without the same
        // before/after care a second handle() in one process would print a
        // duplicate a previous run already reported.
        foreach (array_slice($adoptProducts->duplicates, $duplicatesBefore) as $duplicate) {
            // Named here as well as in the log: an operator running the sweep
            // reads this, and a second Product for one family is something only
            // a person can resolve — we deliberately do not deactivate it.
            $this->warn("  duplicate product {$duplicate} — left active, a product's prices become unsellable if it is deactivated");
        }

        if ($created === 0) {
            $this->info('Stripe is already in step with the catalogue.');

            return self::SUCCESS;
        }

        // "or adopted" on the dry run, and only there. A dry run counts intents
        // without ever calling ensure(), so it cannot know which of them Stripe
        // already holds a Price for — and a dry run is the first thing an
        // operator runs after an interrupted one, which is exactly when some of
        // these will be adopted rather than minted. The live line below has
        // both figures because by then the adoption step has answered.
        $this->info($dryRun
            ? "{$created} object(s) would be created or adopted. Run without --dry-run to create them."
            : "{$minted} object(s) created, {$adopted} adopted in Stripe.");

        return self::SUCCESS;
    }

    /**
     * Why a run is refused outright rather than left to skip its way through.
     *
     * A Stripe Product and a Stripe Price belong to the ACCOUNT that issued
     * them, and a test account and a live account are two accounts. This
     * command skips every row that already carries an id (PlanPrices::inStep()
     * asks the register, not Stripe), so a run after the switch would report
     * "already in step" and change nothing — while recording that the
     * catalogue now belongs to the account it never touched.
     *
     * The registers have to go too, not only the two columns: inStep() takes
     * a registered row as proof on its own for the reverse-charge half, so a
     * cleared pointer with the register left behind would leave exactly that
     * half pointing at the old account, and nothing would say so.
     *
     * Only reached where the OTHER account is established fact. A catalogue
     * whose account was never recorded is a different state with a different
     * cure — see StripeCatalogueMode::belongsToAnotherMode(). Telling that
     * operator to clear anything would be telling them to delete a catalogue
     * their live contracts bill on.
     */
    private function staleCatalogueMessage(): string
    {
        return sprintf(
            'The stored catalogue was created in the [%s] account; this installation is now in [%s] mode. '
            .'Stripe objects belong to the account that issued them, and this command skips every row that '
            .'already carries an id — so it cannot repair this by running again. Clear '
            .'plan_families.stripe_product_id, plan_prices.stripe_price_id and the stripe_plan_prices / '
            .'stripe_addon_prices registers first, then run it. Nothing was created.',
            StripeCatalogueMode::recorded()?->value,
            OperatingMode::current()->value,
        );
    }

    /**
     * The Stripe Prices one priced catalogue row is sold on, brought into step.
     *
     * Both treatments, always: the domestic gross AND the bare net a
     * reverse-charge business is charged. Unconditionally, rather than only when
     * such a customer exists — the Price has to be there BEFORE the checkout that
     * needs it, and a verified business meeting a refused checkout is worse than
     * an unused Price sitting in Stripe.
     *
     * What "in step" means, and the find-or-mint-or-replace that follows from it,
     * belongs to PlanPrices: it is the same question the checkout and the plan
     * swap ask, and asking it in two places is how they come to disagree. All
     * this adds is the reporting, because an operator running a sync has to be
     * able to see which half of which pair moved.
     *
     * Archiving stops a Price being offered and leaves every subscription on it
     * untouched, which is exactly what is wanted: those are moved deliberately,
     * by stripe:reprice-subscriptions.
     *
     * @return int how many objects this row changed, for the run's own count
     */
    private function syncPrice(
        PlanFamily $family,
        PlanVersion $version,
        PlanPrice $price,
        ?string $productId,
        bool $dryRun,
    ): int {
        $prices = app(PlanPrices::class);
        $changed = 0;

        foreach ($this->treatments() as $label => $treatment) {
            if ($prices->inStep($price, $treatment)) {
                continue;
            }

            $this->line(sprintf(
                '  price    %s v%d %s %s %d %s net → %d %s charged',
                $family->key, $version->version, $price->term, $label,
                $price->amount_cents, $price->currency,
                PlanPrices::chargedCents($price, $treatment), $price->currency,
            ));
            $changed++;

            // Nothing to create against: the Product for this family is minted
            // by the caller, and on a dry run it does not exist yet either.
            if ($dryRun || $productId === null) {
                continue;
            }

            $prices->ensure($price, $treatment);
        }

        return $changed;
    }

    /**
     * A Product and four Prices for every module on sale: monthly and yearly,
     * each at the domestic gross and at the bare net.
     *
     * Both terms and both treatments, unconditionally, rather than only the ones
     * somebody has bought a contract on: the Price has to exist BEFORE the booking
     * that needs it, and a customer on a yearly package booking their first module
     * would otherwise discover the gap at the moment their money was due.
     *
     * @return int how many objects were created, for the run's own count
     */
    private function syncModules(bool $dryRun): int
    {
        $catalogue = app(AddonCatalogue::class);
        $prices = app(AddonPrices::class);
        $currency = Subscription::catalogueCurrency();
        $created = 0;

        $keys = array_merge(array_keys((array) config('provisioning.addons')), [AddonCatalogue::STORAGE]);

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

            // Nothing to bill and nothing to mirror. A module priced at zero is
            // either a placeholder or included, and an item at no money would
            // put a line saying so on every invoice a customer ever gets.
            if ($monthly === null || $monthly <= 0) {
                continue;
            }

            foreach ([Subscription::TERM_MONTHLY, Subscription::TERM_YEARLY] as $term) {
                foreach ($this->treatments() as $label => $treatment) {
                    // Asked of the CHARGED figure, so a rate change is seen as a
                    // Price that has to be replaced rather than as one that is
                    // already there.
                    if ($prices->liveFor($key, $monthly, $currency, $term, $treatment) !== null) {
                        continue;
                    }

                    $this->line(sprintf(
                        '  module   %s %s %s %d %s net → %d %s charged',
                        $key, $term, $label,
                        AddonPrices::termNetCents($monthly, $term), $currency,
                        AddonPrices::chargedCents($monthly, $term, $treatment), $currency,
                    ));
                    $created++;

                    if ($dryRun) {
                        continue;
                    }

                    $prices->ensure($key, $monthly, $currency, $term, $treatment);
                }
            }
        }

        return $created;
    }

    /**
     * The two customers everything on sale is mirrored for, labelled for the
     * output so an operator reading a run can see which of a pair moved.
     *
     * Built from TaxTreatment rather than from a rate written down here: it is the
     * single tax authority, and a second list of treatments is how the catalogue
     * would come to sell a figure no invoice agrees with.
     *
     * @return array<string, TaxTreatment>
     */
    private function treatments(): array
    {
        return [
            'domestic' => TaxTreatment::domestic(),
            'reverse-charge' => TaxTreatment::reverseCharge(),
        ];
    }

    /** Versions whose prices are live in Stripe, for the status line. */
    public static function syncedVersions(): int
    {
        return PlanVersion::query()
            ->whereNotNull('published_at')
            ->whereHas('prices', fn ($q) => $q->whereNotNull('stripe_price_id'))
            ->count();
    }
}
