<?php

namespace App\Services\Billing;

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

/**
 * The Stripe Product we were about to create and Stripe already has.
 *
 * The sibling of AdoptStripePrice, one level up, and the gap that made that one
 * fragile. A run that creates a Product and dies before storing its id leaves an
 * orphan; the next run past the idempotency key's twenty-four hours makes a
 * second Product — and from then on activePricesFor() is asked about the new
 * one, so the Price recognition goes blind for the whole family and every
 * orphaned Price on the first Product is unreachable.
 *
 * **No money gate.** A Product carries no amount, no interval and no recurrence,
 * so there is nothing here that could move money and nothing to compare but the
 * metadata. That is the whole of the proof: a Product must CONFIRM an
 * identifying key of ours and contradict none it carries.
 *
 * **A duplicate is reported, never deactivated.** This is the one place the
 * Price side's reasoning does not carry over. Archiving a Price is provably
 * harmless — Stripe goes on billing every subscription already on it, which is
 * what the grandfathering rule has always rested on. Deactivating a Product
 * makes its PRICES unsellable, and contracts can be running on those. What was
 * proven for one is not proven for the other, so it is not done.
 *
 * **Silent about strangers.** Unlike the Price side, which asks only about our
 * own Product and warns when something unexplained sits there, this asks for
 * every active Product on the account. An operator selling something else
 * through the same account is an ordinary state, not a finding, and warning
 * about it would fire on every run for ever.
 */
final class AdoptStripeProduct
{
    /**
     * Products that matched but were not taken, oldest-first order having
     * decided against them. Read by stripe:sync-catalogue for its report — a
     * log line alone is not seen by the operator running the sweep.
     *
     * @var array<int, string>
     */
    public array $duplicates = [];

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

    /**
     * The id of an existing Stripe Product to use instead of creating one.
     *
     * @param  array<string, string>  $metadata  what the create call would send
     * @param  array<int, string>  $identifying  metadata keys that mark a Product as ours
     */
    public function __invoke(array $metadata, array $identifying): ?string
    {
        $metadata = array_map(fn ($value) => (string) $value, $metadata);

        $candidates = [];

        foreach ($this->stripe->activeProducts() as $product) {
            if ($this->contradicts($product['metadata'], $metadata)) {
                continue;
            }

            if (! $this->confirms($product['metadata'], $metadata, $identifying)) {
                continue;
            }

            $candidates[] = $product;
        }

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

        // Oldest first, and on a tie the lower id, so two runs agree. The tie is
        // not merely theoretical: createProduct()'s counter and plantProduct()'s
        // default both start at 1, so a minted Product and a planted orphan can
        // share a `created` — the same collision AdoptStripePrice's usort breaks
        // the same way, documented at FakeStripeClient::createPrice().
        usort($candidates, fn (array $a, array $b) => [$a['created'], $a['id']] <=> [$b['created'], $b['id']]);

        $adopted = array_shift($candidates);

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

            Log::warning('stripe: a second product claims to be the same thing — left alone, not deactivated', [
                'product' => $duplicate['id'],
                'adopted' => $adopted['id'],
                'metadata' => $metadata,
            ]);
        }

        Log::info('stripe: adopted an existing product instead of creating a second one', [
            'product' => $adopted['id'],
            'metadata' => $metadata,
        ]);

        return $adopted['id'];
    }

    /**
     * @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;
    }

    /**
     * Failing to contradict is not enough — an empty metadata bag contradicts
     * nothing, and this list holds every Product on the account.
     *
     * @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;
    }
}
