<?php

namespace App\Services\Stripe;

/**
 * The Idempotency-Key header, formed so that it cannot lie.
 *
 * Stripe replays the first response for a repeated key — but only if the call
 * is repeated exactly. A key that comes back with so much as one added metadata
 * field is answered with HTTP 400 and stays poisoned for twenty-four hours.
 *
 * That happened on 2026-07-29: a run created a Price and died before the row was
 * written, 9da1358 then added the `tax_treatment` metadata field, and the next
 * sweep sent yesterday's key with today's parameters. The sweep was blocked —
 * and so was every module booking, because AddonPrices::ensure() runs inside one.
 *
 * The mistake was conceptual. A key does not state what an object IS; identity
 * is the Price's product, amount, currency and interval, and it is
 * AdoptStripePrice that recognises those. A key states "I have already sent
 * this call", so everything the call sends belongs in it — which is what
 * fingerprint() folds in.
 *
 * **Only for the catalogue calls.** createPrice() and createProduct(): a
 * duplicate there is a Stripe object nothing bills on, recoverable, while a
 * blockade reaches a paying customer. For refund(), cancelSubscription(),
 * addSubscriptionItem() and createCheckoutSession() it is the other way round —
 * a second object is the customer's money taken twice or a package billed
 * twice — so those keep their bare key and Stripe's refusal with it.
 */
final class IdempotencyKey
{
    /** The key for a Price, carrying everything createPrice() puts on the wire. */
    public static function forPrice(
        ?string $key,
        string $productId,
        int $amountCents,
        string $currency,
        string $interval,
        array $metadata,
    ): ?string {
        return self::with($key, self::priceParameters($productId, $amountCents, $currency, $interval, $metadata));
    }

    /** The key for a Product, for the same reason and against the same trap. */
    public static function forProduct(?string $key, string $name, array $metadata): ?string
    {
        return self::with($key, self::productParameters($name, $metadata));
    }

    /**
     * What counts as "the same call" for a Price — public because
     * FakeStripeClient has to ask this exact question too, to decide whether a
     * repeated key is a retry or a changed call. Built inline a second time in
     * the fake, it would only ever match this by coincidence, and a later
     * change to what a Price call fingerprints could drift from what the fake
     * detects without a single test noticing — which is exactly the gap this
     * task exists to close.
     */
    public static function priceParameters(
        string $productId,
        int $amountCents,
        string $currency,
        string $interval,
        array $metadata,
    ): array {
        return [
            'product' => $productId,
            'unit_amount' => $amountCents,
            'currency' => strtolower($currency),
            'interval' => $interval,
            'metadata' => $metadata,
        ];
    }

    /** What counts as "the same call" for a Product — same reason as priceParameters(). */
    public static function productParameters(string $name, array $metadata): array
    {
        return ['name' => $name, 'metadata' => $metadata];
    }

    /**
     * Eight hex characters over the parameters, canonically ordered.
     *
     * Ordered, because PHP keeps insertion order and two callers writing the
     * same metadata in a different order would otherwise send two keys for one
     * call — which would mint two Prices and be a worse bug than the one this
     * closes.
     */
    public static function fingerprint(array $parameters): string
    {
        return substr(sha1(self::canonical($parameters)), 0, 8);
    }

    private static function with(?string $key, array $parameters): ?string
    {
        // Null stays null: a caller who sends no key wants no replay, and
        // inventing one here would change what the call means.
        return $key === null ? null : $key.'-'.self::fingerprint($parameters);
    }

    private static function canonical(array $parameters): string
    {
        ksort($parameters);

        foreach ($parameters as $name => $value) {
            $parameters[$name] = is_array($value) ? self::canonical($value) : (string) $value;
        }

        return json_encode($parameters, JSON_THROW_ON_ERROR);
    }
}
