<?php

namespace App\Services\Billing;

use App\Models\PlanPrice;
use App\Models\StripePlanPrice;
use App\Models\Subscription;
use App\Services\Stripe\StripeClient;

/**
 * The Stripe Price a package is sold on — and there are two of them.
 *
 * A priced catalogue row is mirrored into Stripe twice: once at the DOMESTIC
 * GROSS, which is what the website quotes and what everybody who is charged VAT
 * pays, and once at the BARE NET, which is the whole of what a reverse-charge
 * business in another member state owes. Both are live at the same time, both
 * hang off the same Product, and which one a checkout uses is TaxTreatment's
 * decision and nobody else's.
 *
 * Before this, one Price served everybody. A verified EU business was charged
 * 214,80 € for a 179,00 € package and their document then stated the whole
 * 214,80 € as net at 0 % — a flat 20 % surcharge with no VAT line anywhere for
 * them to reclaim, on exactly the customers who read their invoices.
 *
 * **Where each of the two is written down.** `plan_prices.stripe_price_id` keeps
 * the meaning it has always had — the Price an ordinary domestic customer is sold
 * on — because the checkout, the plan swap and every test read it there and the
 * ordinary sale must have one source. The net Price gets no column of its own:
 * nothing but a reverse-charge business is ever sold on it, so it is read out of
 * `stripe_plan_prices`, where `reverse_charge` says which of the two a row is.
 * That register is also what answers "which of the two is this contract billed
 * on?" — join it on `subscriptions.stripe_price_id`.
 *
 * **Minting is the sync's job and nothing else's.** A Stripe Price is immutable,
 * so a changed figure means a new Price with the old one archived, and doing that
 * from a web request or from a sweep over live contracts would put the catalogue
 * in two hands. Everything outside stripe:sync-catalogue reads; a Price that is
 * missing is reported as a catalogue that has not been synced, which is a
 * sentence an operator can act on.
 */
final class PlanPrices
{
    public function __construct(
        private readonly StripeClient $stripe,
        private readonly AdoptStripePrice $adopt,
    ) {}

    /** What Stripe is asked to take for this row from a customer treated so. */
    public static function chargedCents(PlanPrice $price, TaxTreatment $treatment): int
    {
        return $treatment->chargeCents((int) $price->amount_cents);
    }

    /**
     * The Price a customer with this treatment is sold on, or null where the
     * catalogue has never been mirrored.
     *
     * The domestic answer falls back to the catalogue row's own column, which is
     * where it lived before this register existed and where an install that has
     * not run the sync since still has it. There is deliberately NO such fallback
     * for a reverse-charge business: handing them the domestic Price is the
     * overcharge this whole file exists to end, and refusing the sale until the
     * catalogue is synced is the one outcome that cannot take money nobody owes.
     */
    public function liveFor(PlanPrice $price, TaxTreatment $treatment): ?string
    {
        $registered = $this->registered($price, $treatment);

        if ($registered !== null) {
            return (string) $registered->stripe_price_id;
        }

        if ($treatment->reverseCharge) {
            return null;
        }

        return blank($price->stripe_price_id) ? null : (string) $price->stripe_price_id;
    }

    /**
     * Is Stripe selling this row at the right figure for this treatment?
     *
     * Asked of the CHARGED figure and of the pointer together, which is what
     * makes a second sync run free and a run that died halfway recoverable: a
     * rate change leaves the register live at yesterday's amount, and a crash
     * between Stripe minting the Price and us storing its id leaves the register
     * right and the catalogue row empty.
     */
    public function inStep(PlanPrice $price, TaxTreatment $treatment): bool
    {
        $registered = $this->registered($price, $treatment);

        if ($registered === null) {
            return false;
        }

        // The net Price has no pointer to be out of step with — see the class
        // comment on where each of the two is written down.
        return $treatment->reverseCharge
            || $price->stripe_price_id === $registered->stripe_price_id;
    }

    /**
     * The Price this row is sold on for this treatment, created at Stripe if it
     * has never been told about it.
     *
     * Three outcomes, decided by the charged figure and never by whether an id
     * happens to be stored:
     *
     *  - the register is live at that figure and the catalogue row points at it:
     *    nothing to do;
     *  - a Price for that figure exists but is archived, or is not the one the row
     *    points at — a rate moved and moved back, or a run died between Stripe
     *    creating the Price and us storing its id: it is brought back, at Stripe
     *    as well as here, rather than minted a second time;
     *  - nothing charges it: a new Price, the pointer moved, and whatever it was
     *    pointing at archived — after the replacement exists, never before, so a
     *    failure in between leaves the old Price selling rather than nothing.
     *
     * Null only where the family has no Product yet, which means the run that
     * creates Products has not reached it.
     */
    public function ensure(PlanPrice $price, TaxTreatment $treatment): ?string
    {
        $version = $price->version;
        $family = $version?->family;
        $productId = $family?->stripe_product_id;

        if ($version === null || $family === null || blank($productId)) {
            return null;
        }

        $charged = self::chargedCents($price, $treatment);
        $existing = $this->registered($price, $treatment, includeArchived: true);

        $priceId = $existing?->stripe_price_id;
        $interval = $price->term === Subscription::TERM_YEARLY ? 'year' : 'month';

        if ($priceId === null) {
            $metadata = [
                'plan_family' => $family->key,
                'plan_version' => (string) $version->version,
                'plan_version_id' => (string) $version->id,
                'plan_price_id' => (string) $price->id,
                // Read back by anything that has a Price id and needs to know
                // what kind of sale it was — and by a person looking at
                // Stripe's own dashboard, where two Prices on one Product
                // would otherwise differ only by an amount.
                'tax_treatment' => $treatment->reverseCharge ? 'reverse_charge' : 'domestic',
            ];

            // Asked BEFORE minting: a run that died between Stripe's create and
            // our insert left a Price the register does not know, and the key
            // below stops protecting it after twenty-four hours. `plan_price_id`
            // is what proves such a Price is this row's — one Product carries
            // every version and term of a family, so the amount alone would not.
            $priceId = ($this->adopt)(
                productId: (string) $productId,
                amountCents: $charged,
                currency: (string) $price->currency,
                interval: $interval,
                metadata: $metadata,
                identifying: ['plan_price_id'],
                claimed: fn (string $id) => StripePlanPrice::query()
                    ->where('stripe_price_id', $id)
                    ->exists(),
            );

            $priceId ??= $this->stripe->createPrice(
                productId: (string) $productId,
                amountCents: $charged,
                currency: (string) $price->currency,
                interval: $interval,
                metadata: $metadata,
                // Says "I have already sent this call", nothing more. The
                // CHARGED amount is part of the key, so a run after a rate
                // change cannot replay the Price minted at the old figure. So
                // is the treatment, and it has to be: at a rate of nought the
                // two Prices are the same amount, and one key would have
                // Stripe hand back the same object for both — which the
                // register, where a Price id is unique, would refuse to
                // record twice. The metadata is folded in by IdempotencyKey
                // inside the client, so changing the format above can never
                // again refuse the call for a day. What stops a second Price
                // for one figure is the adoption step above, not this key —
                // Stripe forgets a key after twenty-four hours.
                idempotencyKey: "clupilot-price-{$price->id}-{$charged}"
                    .($treatment->reverseCharge ? '-rc' : ''),
            );
        } elseif ($existing?->archived_at !== null) {
            // Unarchived at Stripe too. Bringing it back only in our own table
            // would leave the catalogue pointing at a Price Stripe refuses to
            // open a checkout for, which is the failure a customer meets and
            // nobody else does.
            $this->stripe->activatePrice($priceId);
        }

        StripePlanPrice::query()->updateOrCreate(
            [
                'plan_price_id' => $price->id,
                'reverse_charge' => $treatment->reverseCharge,
                'charged_cents' => $charged,
            ],
            ['stripe_price_id' => $priceId, 'archived_at' => null],
        );

        $this->archiveSuperseded($price, $treatment, $charged);

        // The pointer is the domestic Price and only ever that. Written straight
        // through the query builder: stripe_price_id is not part of what
        // publication froze, and the model would otherwise have to be re-read.
        if (! $treatment->reverseCharge) {
            PlanPrice::query()->whereKey($price->id)->update(['stripe_price_id' => $priceId]);
        }

        return $priceId;
    }

    /** The priced catalogue row a contract is billed through, or null. */
    public function rowFor(Subscription $subscription): ?PlanPrice
    {
        return PlanPrice::query()
            ->where('plan_version_id', $subscription->plan_version_id)
            ->where('term', $subscription->term)
            ->first();
    }

    /**
     * The Price this contract should be billed on, given who is paying for it.
     *
     * By plan VERSION and term, because that pair is what a Stripe Price is —
     * resolving it by plan name would hand a grandfathered contract today's
     * terms — and then by the customer's own treatment, because a business whose
     * registration was verified after they bought belongs on the net Price from
     * that moment, and one whose registration has lapsed belongs back on the
     * gross one.
     */
    public function liveForSubscription(Subscription $subscription): ?string
    {
        $row = $this->rowFor($subscription);

        return $row === null
            ? null
            : $this->liveFor($row, TaxTreatment::for($subscription->customer));
    }

    /** The register's row for this figure, live unless asked otherwise. */
    private function registered(
        PlanPrice $price,
        TaxTreatment $treatment,
        bool $includeArchived = false,
    ): ?StripePlanPrice {
        return StripePlanPrice::query()
            ->where('plan_price_id', $price->id)
            ->where('reverse_charge', $treatment->reverseCharge)
            ->where('charged_cents', self::chargedCents($price, $treatment))
            ->when(! $includeArchived, fn ($query) => $query->whereNull('archived_at'))
            ->first();
    }

    /**
     * Stop offering every other Price this row was sold on to customers treated
     * the same way.
     *
     * Within ONE treatment, which is the whole reason `reverse_charge` is part of
     * the register's key: a change of rate moves the gross Price and leaves the
     * net one exactly where it was, and a sweep that did not know the difference
     * would archive the half it had not just replaced.
     *
     * 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(PlanPrice $price, TaxTreatment $treatment, int $keepChargedCents): void
    {
        $superseded = StripePlanPrice::query()
            ->where('plan_price_id', $price->id)
            ->where('reverse_charge', $treatment->reverseCharge)
            ->where('charged_cents', '!=', $keepChargedCents)
            ->whereNull('archived_at')
            ->get();

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