<?php

namespace App\Services\Billing;

use App\Services\Stripe\StripeClient;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Log;

/**
 * The Stripe Price we were about to create and Stripe already has.
 *
 * An abandoned run leaves an orphan: Stripe made the Price, our insert never
 * happened, and the table that decides everything afterwards does not know it
 * exists. The idempotency key covers the next twenty-four hours; after that it
 * covers nothing, and the run that follows makes a SECOND live Price for the
 * same money — precisely the duplicate the key was there to prevent. It happened
 * on 2026-07-29 with priority_support at 3480 EUR.
 *
 * So before minting, we ask. Nothing else in the catalogue ever did.
 *
 * **What may be taken over, and why so narrowly.** Same amount, same currency,
 * same interval — a Price at another figure would move money, and moving money
 * is what this must never do: a running contract keeps the Price it was sold on
 * and every booking stays frozen at what it cost that day. Then proof: the
 * candidate must not contradict our metadata, and it must confirm at least one
 * identifying key of it. An active Price at the right money on our own Product
 * with no metadata at all is what a person clicking through Stripe's dashboard
 * leaves behind, and adopting that would be worse than minting a second one.
 * Finally it must be unclaimed — a Price id belongs to one row, which is what
 * the unique index on both registers now enforces.
 *
 * **Several orphans.** The oldest is adopted, because it is the one a lost row
 * is likeliest to have been billing on, and the others are archived at Stripe.
 * Archiving stops a Price being SOLD and leaves every subscription on it exactly
 * where it was — the same grandfathering archiveSuperseded() has always relied
 * on — so the rule "one live Price per figure" is restored without touching a
 * single contract.
 *
 * **Reported through the log, not the console.** AddonPrices::ensure() runs
 * inside a customer's module booking as well as in the sweep, and there is no
 * command line there.
 */
final class AdoptStripePrice
{
    /**
     * How many Prices this run took over rather than minted.
     *
     * Read by stripe:sync-catalogue, which counts an intent BEFORE calling
     * ensure() and so cannot tell the two apart on its own — comparing the Price
     * id before and after does not distinguish them either, because there is no
     * id before in either case.
     */
    public int $adoptions = 0;

    public function __construct(private readonly StripeClient $stripe) {}

    /**
     * The id of an existing Stripe Price to use instead of creating one, or null.
     *
     * @param  array<string, string>  $metadata  what the create call would send
     * @param  array<int, string>  $identifying  metadata keys that mark a Price as ours
     * @param  callable(string): bool  $claimed  is this Price id already in our register?
     */
    public function __invoke(
        string $productId,
        int $amountCents,
        string $currency,
        string $interval,
        array $metadata,
        array $identifying,
        callable $claimed,
    ): ?string {
        // Stripe hands metadata back as strings. An un-cast int here would then
        // never satisfy confirms()'s strict ===, silently disabling adoption for
        // that Price forever and warning on every sweep and every booking. One
        // cast here makes the class independent of what a caller happens to pass.
        $metadata = array_map(fn ($value) => (string) $value, $metadata);

        $candidates = [];

        foreach ($this->stripe->activePricesFor($productId) as $price) {
            // The money gate, and the whole of it — this class promises that no
            // adoption can move money, and until these four moved here it could
            // compare only the first three. A Price at our figure with
            // interval_count 3 bills quarterly; one with transform_quantity
            // divide_by 10 charges a customer holding three packs for one; a
            // metered or tiered one does not charge an amount at all. Stripe's
            // dashboard copies metadata when it duplicates a Price, so such a
            // Price can carry our own identifying key and be indistinguishable
            // from ours by every other test here.
            if ($price['unit_amount'] !== $amountCents
                || $price['currency'] !== strtoupper($currency)
                || $price['interval'] !== $interval
                || $price['interval_count'] !== 1
                || $price['usage_type'] !== 'licensed'
                || $price['transform_quantity']
                || $price['billing_scheme'] !== 'per_unit') {
                continue;
            }

            if ($claimed($price['id'])) {
                continue;
            }

            // Contradicts on something it carries: another Price of ours, not a
            // mystery. Silent — at a VAT rate of nought the two treatments share
            // an amount, and this would otherwise warn on every sweep.
            if ($this->contradicts($price['metadata'], $metadata)) {
                continue;
            }

            if (! $this->confirms($price['metadata'], $metadata, $identifying)) {
                // Once a day per Price. An orphan nobody can adopt is a STATE,
                // not an event, and this runs in a customer's module booking as
                // well as in the sweep — without a throttle it writes a line
                // every time anybody books anything, for as long as the orphan
                // exists, and the clean-up command that would end it is a
                // separate one. Cache::add is this repo's throttle; see
                // App\Livewire\Billing.
                if (Cache::add('stripe:unexplained-price:'.$price['id'], true, now()->addDay())) {
                    Log::warning('stripe: left an unexplained active price alone rather than adopting it', [
                        'price' => $price['id'],
                        'product' => $productId,
                        'amount_cents' => $amountCents,
                        'currency' => strtoupper($currency),
                        'interval' => $interval,
                    ]);
                }

                continue;
            }

            $candidates[] = $price;
        }

        if ($candidates === []) {
            return null;
        }

        // Oldest first, and on a tie the lower id — so the same two orphans give
        // the same answer on every run, whatever order Stripe lists them in.
        usort($candidates, fn (array $a, array $b) => [$a['created'], $a['id']] <=> [$b['created'], $b['id']]);

        $adopted = array_shift($candidates);

        foreach ($candidates as $duplicate) {
            $this->stripe->archivePrice($duplicate['id']);

            Log::warning('stripe: stopped selling a duplicate price for one figure', [
                'price' => $duplicate['id'],
                'adopted' => $adopted['id'],
                'product' => $productId,
                'amount_cents' => $amountCents,
            ]);
        }

        // Only when it differs, so a sweep over a healthy catalogue makes no
        // writes at Stripe at all. Compared through array_intersect_key() rather
        // than a bare !==, for two reasons: Stripe MERGES a metadata write rather
        // than replacing it, so a Price can carry a key of its own that a write
        // would never remove and a bare !== would then never call equal — and
        // PHP's array !== is key-order sensitive, so Stripe handing the same
        // values back in a different order would trigger a write every single
        // sweep. ksort() on both sides settles the order; the intersect settles
        // the extra key, by looking only at what WE would send.
        $overlap = array_intersect_key($adopted['metadata'], $metadata);
        $wanted = $metadata;
        ksort($overlap);
        ksort($wanted);

        if ($overlap !== $wanted) {
            $this->stripe->updatePriceMetadata($adopted['id'], $metadata);
        }

        Log::info('stripe: adopted an existing price instead of creating a second one', [
            'price' => $adopted['id'],
            'product' => $productId,
            'amount_cents' => $amountCents,
            'currency' => strtoupper($currency),
            'interval' => $interval,
        ]);

        $this->adoptions++;

        return $adopted['id'];
    }

    /**
     * Does this Price say something about itself that we do not?
     *
     * @param  array<string, string>  $found
     * @param  array<string, string>  $expected
     */
    private function contradicts(array $found, array $expected): bool
    {
        foreach ($expected as $key => $value) {
            if (array_key_exists($key, $found) && $found[$key] !== $value) {
                return true;
            }
        }

        return false;
    }

    /**
     * Does it prove it is ours?
     *
     * Failing to contradict is not enough — an empty metadata bag contradicts
     * nothing. At least one identifying key has to be there and agree.
     *
     * @param  array<string, string>  $found
     * @param  array<string, string>  $expected
     * @param  array<int, string>  $identifying
     */
    private function confirms(array $found, array $expected, array $identifying): bool
    {
        foreach ($identifying as $key) {
            if (isset($found[$key]) && $found[$key] === ($expected[$key] ?? null)) {
                return true;
            }
        }

        return false;
    }
}
