<?php

namespace App\Console\Commands;

use App\Models\PlanFamily;
use App\Models\StripeAddonPrice;
use App\Models\StripePlanPrice;
use App\Services\Stripe\StripeClient;
use Illuminate\Console\Command;

/**
 * The Prices at our Products that no row of ours knows.
 *
 * App\Services\Billing\AdoptStripePrice takes over an orphan it can prove is
 * ours. What it will not take over — a Price carrying none of our metadata, or
 * one that charges in a way we never mint — it names in the log and leaves,
 * because adopting a stranger's Price is worse than minting a second one. That
 * was the right call and it left an operator with a log line and no way to act
 * on it.
 *
 * **What this lists is wider than that log line.** Every active RECURRING Price
 * at one of our Products that no row of ours knows — adoptable or not. The only
 * question asked below is whether a register row holds the id; nothing here
 * re-asks AdoptStripePrice whether it would have taken the Price over.
 *
 * Recurring, because activePricesFor() sends `type: recurring` as well as
 * `active: true`. That filter can hide somebody else's one-time Price from the
 * list; it can never hide one of ours. createPrice() always sends
 * `recurring[interval]`, and it is the only call that mints a Price at one of
 * our Products at all — the setup fee is priced inline at the checkout, under an
 * ad-hoc Product of its own. So the filter errs toward listing too little, never
 * toward missing an orphan of ours.
 *
 * **So stripe:sync-catalogue runs first.** Until it has, an orphan listed here
 * may be one the next sync would simply have adopted, and --archive on that one
 * is worse than it looks. Archiving it makes it invisible to the very step that
 * would have rescued it: activePricesFor() asks Stripe for ACTIVE prices only,
 * so adoption finds nothing and the sync falls through to createPrice() — under
 * the same idempotency key the crashed run used, because the key is built from
 * what is being sold and everything the call puts on the wire (see
 * App\Services\Stripe\IdempotencyKey), and nothing about either has changed. For
 * twenty-four hours Stripe answers a repeated key by replaying the response it
 * stored rather than doing anything: the archived Price's id, in the body it had
 * while it was still live. The register writes that id down as the live Price
 * for the row, and a checkout on it is refused.
 *
 * Without --archive this reports and touches nothing. With it, the orphans are
 * archived at Stripe, which is harmless for everything THIS codebase sells, for
 * the reason it always is here: Stripe goes on billing every subscription
 * already on an archived Price, it only stops being SOLD — and nothing this
 * codebase sells is sold on a Price no row knows. That reasoning reaches no
 * further than this codebase. A Price an operator wired up by hand — a
 * SUBSCRIPTION payment link built in Stripe's own dashboard against one of our
 * Products — is exactly the Price carrying none of our metadata described above,
 * and --archive withdraws it too. A one-time link is the other side of the
 * recurring filter: neither listed nor archived, and so neither protected nor
 * disturbed by this command.
 */
class SweepOrphanStripePrices extends Command
{
    protected $signature = 'stripe:sweep-orphan-prices
        {--archive : Stop selling the orphans instead of only listing them}
        {--dry-run : Show what would be archived without touching Stripe}';

    protected $description = 'List the Stripe prices at our products that no row knows, and optionally archive them — run stripe:sync-catalogue first';

    public function handle(StripeClient $stripe): int
    {
        $dryRun = (bool) $this->option('dry-run');
        $archive = (bool) $this->option('archive');

        // Checked on EVERY path, unlike stripe:sync-catalogue's --dry-run,
        // which touches nothing because everything it compares is already in
        // our own tables. This command has no local-only mode: the report —
        // the whole point of it, dry-run or not — is activePricesFor(), a
        // live call. Skipping this check under --dry-run would not skip the
        // call, only the graceful error in front of it.
        if (! $stripe->isConfigured()) {
            $this->error('Stripe is not configured (STRIPE_SECRET is empty). Nothing was read.');

            return self::FAILURE;
        }

        $known = StripeAddonPrice::query()->pluck('stripe_price_id')
            ->merge(StripePlanPrice::query()->pluck('stripe_price_id'))
            ->filter()
            ->flip();

        $found = 0;

        foreach ($this->products() as $productId) {
            foreach ($stripe->activePricesFor($productId) as $price) {
                if ($known->has($price['id'])) {
                    continue;
                }

                $found++;

                $this->line(sprintf(
                    '  orphan   %s  %d %s %s  product %s  %s',
                    $price['id'],
                    $price['unit_amount'],
                    $price['currency'],
                    $price['interval'],
                    $productId,
                    $price['metadata'] === [] ? 'no metadata' : json_encode($price['metadata']),
                ));

                if ($archive && ! $dryRun) {
                    $stripe->archivePrice($price['id']);
                }
            }
        }

        $this->newLine();

        if ($found === 0) {
            $this->info('Every active price at our products is accounted for.');

            return self::SUCCESS;
        }

        // Said on every path that found something, including after --archive
        // has already run: the only question asked above is whether a row holds
        // the id, so an orphan in this list may be one stripe:sync-catalogue
        // would have adopted. Archiving that one is the failure this class's
        // docblock describes, and an operator who has just done it needs to
        // know as much as one who is about to.
        $this->warn('  Run stripe:sync-catalogue first — an orphan listed here may be one the next sync would adopt.');

        $this->info(match (true) {
            $dryRun && $archive => "{$found} orphan(s) would be archived. Run without --dry-run to archive them.",
            $archive => "{$found} orphan(s) archived. Existing subscriptions on them keep billing.",
            default => "{$found} orphan(s) found. Once the sync has run, --archive stops selling what is still listed.",
        });

        return self::SUCCESS;
    }

    /**
     * Every Stripe Product this platform owns: one per plan family, one per
     * module. Read from our own rows rather than from Stripe, because a Product
     * we do not know is not one whose Prices we can judge.
     *
     * @return array<int, string>
     */
    private function products(): array
    {
        return PlanFamily::query()->whereNotNull('stripe_product_id')->pluck('stripe_product_id')
            ->merge(StripeAddonPrice::query()->pluck('stripe_product_id'))
            ->filter()
            ->unique()
            ->values()
            ->all();
    }
}
