<?php

namespace App\Services\Billing;

use App\Models\StripeAddonPrice;
use App\Models\Subscription;
use App\Services\Stripe\StripeClient;
use Illuminate\Database\UniqueConstraintViolationException;

/**
 * The Stripe Price a module is billed on, found or minted.
 *
 * Modules had no place in Stripe at all, which is why they were charged in the
 * month they were booked and never again: nothing ever became an item on the
 * subscription. This is the missing half of the catalogue mirror — the module
 * side of what stripe:sync-catalogue does for packages.
 *
 * **Why a module needs more than one Price.** A booking is frozen at the price
 * it was booked at, and a Stripe Price is immutable. So a customer who took
 * storage at ten euros keeps billing against the ten-euro Price while everyone
 * who books it tomorrow bills against the twelve-euro one, and both stay live.
 * The interval belongs to the Price as well, and every item on one subscription
 * has to share it — so a module on a yearly contract is a yearly Price of twelve
 * times the monthly figure, which is exactly what a yearly customer pays for it.
 *
 * **Minting on demand, not only from the sweep.** The command pre-creates
 * today's prices so the ordinary booking finds one waiting. It cannot cover a
 * discounted grant, or a customer grandfathered on a figure the catalogue left
 * behind years ago — and refusing to bill those would be the same bug in a new
 * place. Stripe's idempotency key makes the on-demand path safe to repeat.
 *
 * **What the Price charges depends on who is billed.** `amount_cents` on the row
 * is what Stripe takes: the catalogue's net figure with the domestic rate on it
 * for everybody who is charged VAT, because the figure on the price sheet is the
 * figure charged — and the bare net for a business in another member state whose
 * VAT id is verified, because reverse charge means no VAT is owed to us at all
 * and the net is the whole of it. So a module has TWO live Prices per interval,
 * not one, and `reverse_charge` on the row says which is which.
 *
 * `net_cents` beside it is the catalogue figure the Price was formed from, which
 * is what a booking is frozen at and what identifies the Price when the rate
 * moves. A Price at the wrong figure is archived rather than edited: Stripe does
 * not allow editing one, and a contract may still be billing on it.
 */
final class AddonPrices
{
    public function __construct(
        private readonly StripeClient $stripe,
        private readonly AdoptStripePrice $adopt,
    ) {}

    /**
     * The net a whole term of this module costs.
     *
     * A module is priced per month; a yearly contract bills a year of it at
     * once, in step with the package beside it. Twelve months exactly, and
     * grossed ONCE afterwards rather than twelve times — twelve rounded gross
     * months can differ from a rounded gross year by a cent, and the year is the
     * figure the customer actually pays.
     */
    public static function termNetCents(int $monthlyNetCents, string $term): int
    {
        return $term === Subscription::TERM_YEARLY ? $monthlyNetCents * 12 : $monthlyNetCents;
    }

    /**
     * What Stripe is asked to take for a term of this module from a customer
     * treated so.
     *
     * The treatment is a parameter rather than the domestic one assumed, because
     * a reverse-charge business owes the bare net and there is a Price of their
     * own for it. Grossed ONCE, after the term is formed, for the reason above.
     */
    public static function chargedCents(int $monthlyNetCents, string $term, TaxTreatment $treatment): int
    {
        return $treatment->chargeCents(self::termNetCents($monthlyNetCents, $term));
    }

    /**
     * The Stripe Price for this module at this money on this term, creating it
     * if Stripe has never been told about it.
     *
     * Null when there is nothing to bill: a module given away costs nothing, and
     * a zero-amount item on a subscription is a line on every invoice that says
     * the customer owes nothing for it.
     */
    public function ensure(
        string $addonKey,
        int $unitNetCents,
        string $currency,
        string $term,
        TaxTreatment $treatment,
    ): ?string {
        if ($unitNetCents <= 0) {
            return null;
        }

        $interval = $term === Subscription::TERM_YEARLY ? 'year' : 'month';
        $currency = strtoupper($currency);
        $reverseCharge = $treatment->reverseCharge;

        $netCents = self::termNetCents($unitNetCents, $term);
        $amount = $treatment->chargeCents($netCents);

        $existing = StripeAddonPrice::query()
            ->where('addon_key', $addonKey)
            ->where('reverse_charge', $reverseCharge)
            ->where('amount_cents', $amount)
            ->where('currency', $currency)
            ->where('interval', $interval)
            ->first();

        if ($existing !== null) {
            // Brought back rather than minted again. A rate that moves and then
            // moves back — or a run interrupted between Stripe and us — must not
            // leave a second Stripe Price for one figure. Brought back at Stripe
            // too where it had been archived there: a Price that is only live in
            // our own table is one a checkout is refused for.
            if ($existing->archived_at !== null) {
                $this->stripe->activatePrice((string) $existing->stripe_price_id);
            }

            $existing->update(['archived_at' => null, 'net_cents' => $netCents]);
            $this->archiveSuperseded($addonKey, $reverseCharge, $netCents, $currency, $interval, $amount);

            return (string) $existing->stripe_price_id;
        }

        $productId = $this->product($addonKey);

        $metadata = [
            // Read back when a Stripe invoice line has to be turned into
            // wording a customer can read — see StripeInvoiceLines.
            'addon' => $addonKey,
            // Which of the module's two Prices this is, for anyone reading
            // Stripe's own dashboard, where they would otherwise differ only
            // by an amount.
            'tax_treatment' => $reverseCharge ? 'reverse_charge' : 'domestic',
        ];

        // Asked BEFORE minting, because a run that died between Stripe's create
        // and our insert left a Price no table of ours knows — and the key below
        // stops protecting it after twenty-four hours. See AdoptStripePrice for
        // what may be taken over and why so narrowly.
        $priceId = ($this->adopt)(
            productId: $productId,
            amountCents: $amount,
            currency: $currency,
            interval: $interval,
            metadata: $metadata,
            identifying: ['addon'],
            claimed: fn (string $id) => StripeAddonPrice::query()
                ->where('stripe_price_id', $id)
                ->exists(),
        );

        $priceId ??= $this->stripe->createPrice(
            productId: $productId,
            amountCents: $amount,
            currency: $currency,
            interval: $interval,
            metadata: $metadata,
            // Says "I have already sent this call", nothing more. The amount is
            // the CHARGED one and the treatment is in there because at a rate of
            // nought the two Prices are the same amount — but what stops a
            // second Price for one figure is AdoptStripePrice above, not this:
            // Stripe forgets a key after twenty-four hours. The metadata is
            // folded in by IdempotencyKey inside the client, so changing the
            // format above can never again refuse the call for a day.
            idempotencyKey: "clupilot-addon-price-{$addonKey}-{$interval}-{$amount}-{$currency}"
                .($reverseCharge ? '-rc' : ''),
        );

        $this->remember($addonKey, $reverseCharge, $amount, $netCents, $currency, $interval, $productId, $priceId);
        $this->archiveSuperseded($addonKey, $reverseCharge, $netCents, $currency, $interval, $amount);

        return $priceId;
    }

    /**
     * The live Price for a module at a catalogue figure, or null.
     *
     * Asked with the MONTHLY net the booking is frozen at, because that is what
     * a caller has: the charged figure is worked out here, from the one place
     * that knows the rate — and from the treatment, because a reverse-charge
     * business is billed on a Price of their own at the bare net.
     */
    public function liveFor(
        string $addonKey,
        int $unitNetCents,
        string $currency,
        string $term,
        TaxTreatment $treatment,
    ): ?string {
        if ($unitNetCents <= 0) {
            return null;
        }

        return $this->find(
            $addonKey,
            self::chargedCents($unitNetCents, $term, $treatment),
            $currency,
            $term === Subscription::TERM_YEARLY ? 'year' : 'month',
            $treatment->reverseCharge,
        );
    }

    /** The live Price we already have for this charged figure, or null. */
    public function find(
        string $addonKey,
        int $amountCents,
        string $currency,
        string $interval,
        bool $reverseCharge,
    ): ?string {
        $id = StripeAddonPrice::query()
            ->where('addon_key', $addonKey)
            ->where('reverse_charge', $reverseCharge)
            ->where('amount_cents', $amountCents)
            ->where('currency', strtoupper($currency))
            ->where('interval', $interval)
            ->whereNull('archived_at')
            ->value('stripe_price_id');

        return is_string($id) && $id !== '' ? $id : null;
    }

    /**
     * Stop offering every other Price for this module at this catalogue figure.
     *
     * Only ones formed from the SAME net: a customer grandfathered at ten euros
     * keeps their own Price, and archiving that would leave their subscription
     * billing on something Stripe no longer sells. What is archived here is the
     * Price for today's figure at yesterday's rate.
     *
     * And only ones for the same TREATMENT. A change of rate moves the gross
     * Price and leaves the net one exactly where it was, so a sweep that did not
     * know the difference would archive the half it had not just replaced — and
     * every reverse-charge business would find their module Price withdrawn the
     * next time the VAT rate moved.
     *
     * Archived in Stripe as well as here. The Price object stays, as Stripe
     * requires, because a contract may still be billing on it until
     * stripe:reprice-subscriptions has moved it.
     */
    private function archiveSuperseded(
        string $addonKey,
        bool $reverseCharge,
        int $netCents,
        string $currency,
        string $interval,
        int $keepAmountCents,
    ): void {
        $superseded = StripeAddonPrice::query()
            ->where('addon_key', $addonKey)
            ->where('reverse_charge', $reverseCharge)
            ->where('net_cents', $netCents)
            ->where('currency', $currency)
            ->where('interval', $interval)
            ->where('amount_cents', '!=', $keepAmountCents)
            ->whereNull('archived_at')
            ->get();

        foreach ($superseded as $price) {
            $this->stripe->archivePrice((string) $price->stripe_price_id);
            $price->update(['archived_at' => now()]);
        }
    }

    /**
     * The Stripe Product every Price for this module hangs off.
     *
     * One per module, however many Prices it accumulates — that is what a
     * Product is for, and it is what makes the module readable in Stripe's own
     * dashboard rather than a list of anonymous amounts.
     */
    private function product(string $addonKey): string
    {
        $existing = StripeAddonPrice::query()
            ->where('addon_key', $addonKey)
            ->value('stripe_product_id');

        if (is_string($existing) && $existing !== '') {
            return $existing;
        }

        $metadata = ['addon' => $addonKey];

        // Asked before minting. A run that created this Product and died before
        // any row carried its id leaves an orphan — and a second Product would
        // make activePricesFor() ask about the wrong one, blinding the Price
        // recognition for this module entirely.
        $adopted = app(AdoptStripeProduct::class)($metadata, ['addon']);

        return $adopted ?? $this->stripe->createProduct(
            app(AddonCatalogue::class)->name($addonKey),
            $metadata,
            // A retry's guard for twenty-four hours and nothing beyond them;
            // what stops a second Product is the adoption step above.
            idempotencyKey: "clupilot-addon-product-{$addonKey}",
        );
    }

    private function remember(
        string $addonKey,
        bool $reverseCharge,
        int $amountCents,
        int $netCents,
        string $currency,
        string $interval,
        string $productId,
        string $priceId,
    ): void {
        try {
            StripeAddonPrice::create([
                'addon_key' => $addonKey,
                'reverse_charge' => $reverseCharge,
                'amount_cents' => $amountCents,
                'net_cents' => $netCents,
                'currency' => $currency,
                'interval' => $interval,
                'stripe_product_id' => $productId,
                'stripe_price_id' => $priceId,
            ]);
        } catch (UniqueConstraintViolationException) {
            // Two bookings of the same module landed together. Stripe replayed
            // one Price for both — the idempotency key saw to that — so there is
            // nothing to correct here beyond letting the first row stand.
            //
            // Since the unique index on `stripe_price_id` below, this catch has a
            // SECOND meaning: another TUPLE claims this Price id, which is two
            // callers adopting the same orphan at once. The outcome then is no row
            // for this tuple at all, not "the first row stands" — the row that
            // exists belongs to the other tuple. That self-heals on the next
            // ensure(): the orphan is claimed, so adoption refuses it and
            // createPrice() mints this tuple its own Price. No money moves in the
            // meantime, because both callers matched the same `unit_amount`
            // before adopting; what a caller gets back here is the Price id it
            // asked for, and it charges what was asked.
            //
            // What keeps two ROWS off one Price id is the `claimed` callback in
            // ensure(), asked before minting. The unique index on
            // `stripe_price_id` (`stripe_addon_prices_price_unique`, since
            // 2026_07_31_210000_one_row_per_stripe_price, matching
            // `stripe_plan_prices.stripe_price_id` on the plan side) is the net
            // UNDER that callback, not a substitute for it — two rows sharing one
            // Price would be worse than the ordinary duplicate this catch
            // handles: archiving one — the ordinary response to a superseded
            // figure — would then withdraw the very Price the other row is still
            // selling.
        }
    }
}
