<?php

namespace App\Console\Commands;

use App\Actions\MoveStripeSubscriptionPrice;
use App\Actions\SyncStripeAddonItems;
use App\Models\Subscription;
use App\Services\Billing\PlanPrices;
use App\Services\Stripe\StripeClient;
use Illuminate\Console\Command;

/**
 * Move contracts that are still billing on a superseded Stripe Price.
 *
 * stripe:sync-catalogue mints the Price that charges today's figure and archives
 * the one it replaces — and that is all it does. Archiving a Price in Stripe
 * stops it being SOLD; every subscription already on it goes on being billed at
 * the old amount for ever. This is the second half, and it is a command of its
 * own because it touches live contracts and money, which is a decision an owner
 * makes deliberately rather than a side effect of syncing a price list.
 *
 * Run it after every stripe:sync-catalogue that reported a change:
 *
 *     php artisan stripe:sync-catalogue --dry-run
 *     php artisan stripe:sync-catalogue
 *     php artisan stripe:reprice-subscriptions --dry-run
 *     php artisan stripe:reprice-subscriptions
 *
 * **Nothing is prorated.** The customer has already paid for the term they are
 * in, at whatever was taken at the time; charging the difference for days already
 * served would be a bill nobody agreed to, and Stripe's own default would raise
 * exactly that. From the next cycle the right amount is taken.
 *
 * **Safe to re-run.** Both halves ask whether the contract is already on the
 * right Price and do nothing when it is, so a second run is free and a run
 * interrupted halfway finishes on the next attempt. The package half never
 * throws — MoveStripeSubscriptionPrice parks its failures on the contract and
 * clupilot:sync-stripe-subscriptions retries them hourly.
 *
 * **Two reasons a contract moves, not one.** The first is a change of FIGURE, as
 * above. The second is a change of CUSTOMER: a business whose VAT id is verified
 * the week after they bought must be billed the net Price from then on, because
 * reverse charge means they owe no VAT to us at all — and one whose registration
 * has lapsed must go back onto the gross one. Neither is a change of package, so
 * nothing else in the system would ever notice, and both converge here on the next
 * run. PlanPrices::liveForSubscription() answers "which Price does this contract
 * belong on?" for both, which is why this command has no resolver of its own: two
 * places working that out is how they come to disagree.
 *
 * The contract's own `price_cents` is not touched by any of this. It is the
 * catalogue's NET figure, it is frozen, and PlanChange prorates against it.
 */
class RepriceStripeSubscriptions extends Command
{
    protected $signature = 'stripe:reprice-subscriptions
        {--dry-run : Show which contracts would move without touching Stripe}
        {--limit=500 : How many contracts to look at in one run}';

    protected $description = 'Move live contracts onto the Stripe prices the catalogue now sells';

    public function handle(
        StripeClient $stripe,
        MoveStripeSubscriptionPrice $move,
        SyncStripeAddonItems $modules,
        PlanPrices $prices,
    ): int {
        $dryRun = (bool) $this->option('dry-run');

        if (! $dryRun && ! $stripe->isConfigured()) {
            $this->error('Stripe is not configured (STRIPE_SECRET is empty). Nothing was moved.');

            return self::FAILURE;
        }

        // Active contracts Stripe actually bills. A cancelled one is not billed
        // again, and a granted one has no Stripe subscription at all — moving
        // either would be housekeeping on something that charges nobody.
        $contracts = Subscription::query()
            ->whereNotNull('stripe_subscription_id')
            ->where('status', 'active')
            ->orderBy('id')
            ->limit((int) $this->option('limit'))
            ->get();

        $packages = 0;
        $items = 0;
        $failed = 0;

        foreach ($contracts as $contract) {
            // Whether the catalogue prices this contract's package at all, asked
            // separately from WHICH Price it should be on. A version that was never
            // priced is a contract this command has nothing to say about; one that
            // is priced but has no Stripe Price for this customer's treatment is a
            // contract that must move, and the move is what mints it.
            $priced = $prices->rowFor($contract) !== null;
            $target = $priced ? $prices->liveForSubscription($contract) : null;

            if ($priced && $contract->stripe_price_id !== $target) {
                $this->line(sprintf(
                    '  package  %s  %s → %s',
                    $contract->uuid,
                    $contract->stripe_price_id ?? '(unknown)',
                    // Null where the catalogue has no Price for what this customer
                    // should be charged. Said out loud rather than skipped: the move
                    // below parks the reason on the contract and the run warns about
                    // it, and the cure is one command.
                    $target ?? '(not synced)',
                ));
                $packages++;

                // PRORATE_NONE: the term in progress is paid for. See the class
                // comment — this must never raise a charge for days already
                // served at the old figure.
                if (! $dryRun && ! $move($contract, StripeClient::PRORATE_NONE)) {
                    $failed++;
                }
            }

            foreach ($modules->reprice($contract, $dryRun) as $item) {
                $this->line(sprintf(
                    '  module   %s  %s  %s → %s',
                    $contract->uuid,
                    $item['addon'],
                    $item['from'] ?? '(none)',
                    $item['to'] ?? '(to be created)',
                ));
                $items++;
            }
        }

        $this->newLine();

        if ($packages === 0 && $items === 0) {
            $this->info('Every live contract is already on the price the catalogue sells.');

            return self::SUCCESS;
        }

        $this->info($dryRun
            ? "{$packages} package(s) and {$items} module item(s) would move. Run without --dry-run to move them."
            : "{$packages} package(s) and {$items} module item(s) moved.");

        if ($failed > 0) {
            // Parked on the contract by the action and retried hourly by
            // clupilot:sync-stripe-subscriptions, so this is a warning and not a
            // failure — but somebody watching the output has to be told.
            $this->warn("{$failed} package move(s) did not reach Stripe and are parked for the hourly retry.");
        }

        return self::SUCCESS;
    }
}
