<?php

namespace App\Services\Billing;

use App\Models\Subscription;
use Illuminate\Support\Carbon;
use RuntimeException;

/**
 * The PREVIEW of a plan change: what it would cost, and whether it is allowed
 * yet.
 *
 * A preview, not an invoice. Since Stripe took over the billing cycle, the
 * amount a customer is actually charged for an upgrade comes back on Stripe's
 * proration invoice, and that is the authority — it accounts for tax, for
 * credit already on the account, and for the exact second the change lands, and
 * ours cannot. What this is for is the sentence shown before someone confirms,
 * so nobody is asked to agree to a number they have not seen.
 *
 * The two are expected to agree to the cent in the ordinary case. Where they do
 * not, the invoice wins and the proof register records what was charged.
 *
 * Two rules, both the customer's rules:
 *
 *  - **Upgrading is immediate and pro rata.** They get the bigger plan now and
 *    pay only for the days left in the term they already paid for, minus what
 *    the old plan was worth over those same days.
 *  - **Downgrading waits for the end of the term.** Someone on a yearly
 *    contract bought a year; they can move down when that year is up, not in
 *    month three. A month is a month.
 *
 * Everything is computed against the SUBSCRIPTION's frozen price, never against
 * today's catalogue — that is the whole point of the snapshot.
 */
final readonly class PlanChange
{
    public function __construct(
        public bool $isUpgrade,
        public bool $allowedNow,
        public int $chargeCents,
        public int $creditCents,
        public ?Carbon $effectiveAt,
        public int $remainingDays,
        public int $termDays,
    ) {}

    public static function evaluate(Subscription $subscription, string $targetPlan, ?Carbon $at = null): self
    {
        $at = $at?->copy() ?? now();

        // A cancelled contract has nothing left to change. Without this, a
        // caller trusting allowedNow would provision and bill an upgrade for a
        // customer who has already left.
        if ($subscription->status !== 'active') {
            return new self(
                isUpgrade: false,
                allowedNow: false,
                chargeCents: 0,
                creditCents: 0,
                effectiveAt: null,
                remainingDays: 0,
                termDays: 1,
            );
        }

        $target = Subscription::snapshotFrom($targetPlan, $subscription->term);

        // Direction comes from the plan's rank, never from the prices: a
        // grandfathered business plan can cost less than today's team plan, and
        // comparing those would call losing resources an "upgrade" and charge
        // for it immediately. Prices decide the amount, not the direction.
        $isUpgrade = (int) $target['tier'] > (int) $subscription->tier;

        $termDays = max(1, (int) $subscription->current_period_start->diffInDays($subscription->current_period_end));

        // Rounded UP while any service remains: with less than a day left,
        // flooring would hand out an upgrade for nothing. Zero only once the
        // period is genuinely over.
        $hoursLeft = $at->diffInHours($subscription->current_period_end, absolute: false);
        $remainingDays = $hoursLeft > 0
            ? min($termDays, (int) ceil($hoursLeft / 24))
            : 0;

        if (! $isUpgrade) {
            // Downgrade: nothing changes and nothing is owed until the term
            // ends — but once it HAS ended it must actually be applicable, or
            // the job that is supposed to carry it out never can.
            //
            // When the move is already BOOKED, the date it comes due is the one
            // stamped on the contract at the moment it was booked, not today's
            // period end. Stripe moves the period end on every renewal, and a
            // due date that moved with it could never arrive: each renewal
            // pushed the downgrade out by another term and billed the customer
            // again for the package they had asked to leave. An unbooked move —
            // the preview on the shop page — has no stamp and is answered from
            // the period the customer is in, which is what they are being shown.
            $dueAt = $subscription->pending_plan === $targetPlan && $subscription->pending_effective_at !== null
                ? $subscription->pending_effective_at
                : $subscription->current_period_end;

            $termOver = $at->greaterThanOrEqualTo($dueAt);

            return new self(
                isUpgrade: false,
                allowedNow: $termOver,
                chargeCents: 0,
                creditCents: 0,
                effectiveAt: $termOver ? $at->copy() : $dueAt->copy(),
                remainingDays: $remainingDays,
                termDays: $termDays,
            );
        }

        // The period is already over: there is nothing left to prorate, and an
        // upgrade here belongs to the next term, not to this one.
        if ($remainingDays === 0) {
            return new self(
                isUpgrade: true,
                allowedNow: false,
                chargeCents: 0,
                creditCents: 0,
                effectiveAt: $subscription->current_period_end->copy(),
                remainingDays: 0,
                termDays: $termDays,
            );
        }

        // Pro rata on both sides: the unused part of what they already paid is
        // worth something, and only the difference is charged.
        $unusedOld = (int) round($subscription->price_cents * $remainingDays / $termDays);
        $newForRest = (int) round($target['price_cents'] * $remainingDays / $termDays);

        return new self(
            isUpgrade: true,
            allowedNow: true,
            chargeCents: max(0, $newForRest - $unusedOld),
            creditCents: 0,
            effectiveAt: $at,
            remainingDays: $remainingDays,
            termDays: $termDays,
        );
    }

    /**
     * Carry the custom domain over — or take it away — once a change has landed.
     *
     * The preview above says what a move would cost; this is the one thing that
     * has to happen after it. A contract that no longer allows a custom domain
     * loses it: the module stops being charged for and the instance goes back
     * to its platform address. A contract that still allows one — because the
     * new package includes it, or because the customer booked the module before
     * moving — is left alone, which is exactly what "keeping it" means.
     *
     * The rule itself is not here. CustomDomainAccess owns it; this is the
     * moment it is applied, sitting beside the preview so nobody changes what a
     * plan change costs without seeing what it does.
     *
     * Returns whether anything was taken away.
     */
    public static function settleCustomDomain(Subscription $subscription): bool
    {
        return app(CustomDomainAccess::class)->enforce($subscription);
    }

    /**
     * What a mid-term downgrade would be worth, for the cases where we grant one
     * anyway — a goodwill move or a support decision. Never offered to the
     * customer as a self-service button, because a downgrade before the term is
     * up is a change to the contract, not a setting.
     */
    public static function goodwillCredit(Subscription $subscription, string $targetPlan, ?Carbon $at = null): int
    {
        $change = self::evaluate($subscription, $targetPlan, $at);

        if ($change->isUpgrade) {
            throw new RuntimeException('An upgrade is charged, not credited.');
        }

        // A sideways move at the same rank has no difference to credit.
        if ((int) Subscription::snapshotFrom($targetPlan, $subscription->term)['tier'] === (int) $subscription->tier) {
            return 0;
        }

        $target = Subscription::snapshotFrom($targetPlan, $subscription->term);
        $difference = $subscription->price_cents - $target['price_cents'];

        // Never negative: a grandfathered plan can be cheaper than the smaller
        // plan costs today, and a downgrade must not invoice the customer for
        // the privilege.
        return max(0, (int) round($difference * $change->remainingDays / $change->termDays));
    }
}
