<?php

namespace App\Console\Commands;

use App\Actions\ApplyPlanChange;
use App\Models\Order;
use App\Models\Subscription;
use Illuminate\Console\Command;
use Throwable;

/**
 * Carries out the plan changes whose moment has come.
 *
 * An upgrade lands when it is paid for — see App\Observers\OrderObserver. A
 * downgrade cannot: someone on a yearly contract bought a year, so moving them
 * down in month three would take away what they have already paid for. It waits
 * for the end of the term, and nothing was waiting with it. This is what does.
 *
 * **How a pending downgrade is known.** By the contract, which carries the
 * package it is moving to and the date that move comes due — `pending_plan` and
 * `pending_effective_at`, stamped by the shop the moment the customer books it.
 *
 * This used to be read off the pending Order instead, with the due date worked
 * out from `subscriptions.current_period_end` on every tick. That date is not a
 * fact about the booking, it is a fact about the billing cycle, and Stripe moves
 * it forward on every renewal: a downgrade booked in March was pushed to the end
 * of April by April's renewal, then to the end of May by May's, and the customer
 * went on paying for the package they had asked to leave. A due date has to be
 * decided once, at the moment the customer decides — so it is stamped then, and
 * only read afterwards.
 *
 * The order is still the customer's own record of the request — it is what the
 * cart shows them and what they remove to change their mind — so it is consumed
 * here alongside the contract, and removing it clears the stamp (see
 * App\Livewire\ConfirmRemoveOrder). What it no longer does is decide WHEN.
 *
 * **Safe to run as often as the scheduler likes.** A contract is picked up only
 * while its stamped date has passed and it is still active, and the stamp is
 * cleared the moment the change has landed — so a second tick finds nothing
 * left to do rather than doing it again.
 */
class ApplyDuePlanChanges extends Command
{
    protected $signature = 'clupilot:apply-due-plan-changes';

    protected $description = 'Apply scheduled downgrades whose term has run out';

    public function handle(ApplyPlanChange $apply): int
    {
        $applied = 0;
        $due = 0;

        // Cancelled contracts are left where they are, and not merely because
        // ApplyPlanChange would refuse them: a customer who has left is not
        // moved onto a smaller package on their way out, and the booking they
        // made goes with the contract it was made on.
        $contracts = Subscription::query()
            ->whereNotNull('pending_plan')
            ->whereNotNull('pending_effective_at')
            ->where('pending_effective_at', '<=', now())
            ->where('status', 'active')
            ->with('customer')
            ->orderBy('id')
            ->cursor();

        foreach ($contracts as $subscription) {
            $due++;
            $plan = (string) $subscription->pending_plan;

            try {
                $apply($subscription, $plan, $this->orderFor($subscription, $plan));

                // Read back rather than assumed: a package the catalogue has
                // since withdrawn cannot be moved onto, and ApplyPlanChange
                // says so by logging and leaving the contract alone. Clearing
                // the stamp there would drop the customer's request silently,
                // so it stays booked and this says so on every tick.
                $subscription->refresh();

                if ((string) $subscription->plan !== $plan) {
                    $this->warn("Contract {$subscription->uuid}: still on {$subscription->plan}, {$plan} was not applied.");

                    continue;
                }

                $subscription->clearPendingPlanChange();
                $applied++;
            } catch (Throwable $e) {
                // One customer's withdrawn package must not stop everybody
                // else's downgrade from landing.
                $this->warn("Contract {$subscription->uuid}: {$e->getMessage()}");
            }
        }

        $this->info("{$due} downgrade(s) due, {$applied} applied.");

        return self::SUCCESS;
    }

    /**
     * The cart entry this booking came from, so it is consumed with the change
     * and the register row files under it.
     *
     * Null is a perfectly ordinary answer — an operator can book a change
     * without a purchase, and the contract is the authority on what was agreed
     * either way.
     */
    private function orderFor(Subscription $subscription, string $plan): ?Order
    {
        return Order::query()
            ->where('customer_id', $subscription->customer_id)
            ->where('type', 'downgrade')
            ->where('status', 'pending')
            ->where('plan', $plan)
            ->orderBy('id')
            ->first();
    }
}
