<?php

namespace App\Console\Commands;

use App\Actions\MoveStripeSubscriptionPrice;
use App\Actions\SyncStripeAddonItems;
use App\Models\Subscription;
use App\Models\SubscriptionAddon;
use Illuminate\Console\Command;

/**
 * The sweep behind the plan change — and behind the module booking.
 *
 * Moving Stripe onto the new price at the moment the change lands is right and
 * it is not enough. Stripe can be unreachable, the key can have been rotated,
 * the catalogue can be a sync behind — and the change has already happened to
 * the contract and to the machine by then, so it is not rolled back. What is
 * left is a customer being charged for a package they are no longer on, and
 * nothing about the contract would ever say so again.
 *
 * ApplyPlanChange parks exactly that on the contract. This picks it up, however
 * long ago it happened.
 *
 * A module booking is the same failure wearing different clothes: the pack is
 * already a bigger disk, the entitlement is already switched on, and if Stripe
 * never heard about it the customer holds a module nobody is billing them for.
 * So it is swept from here too rather than from a second command — one sweep,
 * one schedule entry, one place to look when Stripe has been away.
 *
 * Deliberately hands the work back to the actions rather than doing it here:
 * they own which price, which item and which proration behaviour, and two code
 * paths that both move a subscription is one more than the number that can be
 * kept in agreement.
 */
class SyncStripeSubscriptions extends Command
{
    protected $signature = 'clupilot:sync-stripe-subscriptions {--limit=100}';

    protected $description = 'Retry the plan changes and module bookings that landed here but never reached Stripe';

    public function handle(MoveStripeSubscriptionPrice $move, SyncStripeAddonItems $modules): int
    {
        $status = $this->sweepPlans($move);
        $this->sweepModules($modules);

        return $status;
    }

    private function sweepPlans(MoveStripeSubscriptionPrice $move): int
    {
        // Active contracts only. A cancelled one is not billed again, so moving
        // it onto another price would be housekeeping on something Stripe has
        // already finished with — and an upgrade's proration raised against a
        // contract that has ended would charge a departed customer.
        $parked = Subscription::query()
            ->whereNotNull('stripe_price_sync')
            ->whereNotNull('stripe_subscription_id')
            ->where('status', 'active')
            ->orderBy('id')
            ->limit((int) $this->option('limit'))
            ->get();

        $moved = 0;

        foreach ($parked as $subscription) {
            if ($move->retry($subscription)) {
                $moved++;

                continue;
            }

            // The action has already logged why and counted the attempt. Said
            // again here because somebody is watching this command's output
            // and not the log.
            $this->warn(sprintf(
                'Contract %s: still billed at the old price — %s',
                $subscription->uuid,
                (string) ($subscription->fresh()?->stripe_price_sync['error'] ?? 'unknown'),
            ));
        }

        $this->info("{$parked->count()} contract(s) out of step with Stripe, {$moved} moved.");

        return self::SUCCESS;
    }

    /**
     * Contracts whose modules Stripe is not billing.
     *
     * Found from the BOOKINGS rather than from a parked marker, because the
     * bookings are the authority: a module still running with no item id was
     * never put on the subscription, and one that has stopped while still
     * carrying an item id is being billed after it ended. `stripe_addon_sync` is
     * the record of what went wrong and how often — it is not where the work
     * lives, so a contract whose failure was never written down (a worker killed
     * mid-booking) is still found here.
     */
    private function sweepModules(SyncStripeAddonItems $modules): void
    {
        $outstanding = SubscriptionAddon::query()
            ->where(fn ($q) => $q
                ->where(fn ($active) => $active->whereNull('cancelled_at')->whereNull('cancels_at')->whereNull('stripe_item_id'))
                ->orWhere(fn ($gone) => $gone->whereNotNull('stripe_item_id')
                    ->where(fn ($ended) => $ended->whereNotNull('cancelled_at')->orWhereNotNull('cancels_at'))))
            ->select('subscription_id')
            ->distinct();

        // Active contracts that Stripe actually bills. A granted contract has no
        // Stripe subscription at all and its bookings will never carry an item
        // id — sweeping those would retry, and log, something that is not wrong.
        $contracts = Subscription::query()
            ->whereIn('id', $outstanding)
            ->whereNotNull('stripe_subscription_id')
            ->where('status', 'active')
            ->orderBy('id')
            ->limit((int) $this->option('limit'))
            ->get();

        $settled = 0;

        foreach ($contracts as $subscription) {
            if ($modules($subscription)) {
                $settled++;

                continue;
            }

            $this->warn(sprintf(
                'Contract %s: modules not billed by Stripe — %s',
                $subscription->uuid,
                (string) ($subscription->fresh()?->stripe_addon_sync['error'] ?? 'unknown'),
            ));
        }

        $this->info("{$contracts->count()} contract(s) with modules out of step, {$settled} settled.");
    }
}
