<?php

namespace App\Models;

use App\Services\Billing\AddonPrices;
use App\Services\Billing\PlanCatalogue;
use Illuminate\Database\Eloquent\Concerns\HasUuids;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Support\Carbon;
use RuntimeException;

class Subscription extends Model
{
    use HasFactory;
    use HasUuids;

    public const TERM_MONTHLY = 'monthly';

    public const TERM_YEARLY = 'yearly';

    /**
     * The conditions the customer is owed. Changing any of these after the fact
     * would rewrite a contract, so the model refuses — a price rise applies to
     * new subscriptions, never to existing ones.
     */
    public const FROZEN = [
        'plan', 'plan_version_id', 'term', 'price_cents', 'currency', 'quota_gb', 'traffic_gb',
        'seats', 'ram_mb', 'cores', 'disk_gb', 'performance', 'tier', 'started_at',
    ];

    protected $guarded = [];

    /**
     * Why a customer cancelled, as a fixed list.
     *
     * A free-text box alone answers nothing that can be counted, and "sonstiges"
     * with a sentence beside it is worth more than eight boxes nobody ticks
     * honestly. Keys, not sentences: the wording lives in the settings language
     * files and can be rewritten without rewriting what is on record. (Written
     * that way on purpose — a path with a star and a slash in it ends the
     * docblock it is standing in.)
     *
     * `withdrawal` is deliberately NOT here. Withdrawing is a different act with
     * a different consequence — the whole amount back and the service ending the
     * same day — and it is recorded by WithdrawContract, not by a cancellation
     * carrying a reason that says it was one.
     */
    public const CANCEL_REASONS = [
        'too_expensive',
        'missing_features',
        'switching_provider',
        'no_longer_needed',
        'performance',
        'support',
        'other',
    ];

    protected static function booted(): void
    {
        static::updating(function (self $subscription) {
            $frozen = array_intersect(array_keys($subscription->getDirty()), self::FROZEN);

            if ($frozen !== []) {
                throw new RuntimeException(
                    'A subscription snapshot is immutable; tried to change: '.implode(', ', $frozen).
                    '. Cancel this subscription and start a new one instead.'
                );
            }
        });
    }

    protected function casts(): array
    {
        return [
            'price_cents' => 'integer',
            'quota_gb' => 'integer',
            'traffic_gb' => 'integer',
            'seats' => 'integer',
            'ram_mb' => 'integer',
            'cores' => 'integer',
            'disk_gb' => 'integer',
            'template_vmid' => 'integer',
            'tier' => 'integer',
            'started_at' => 'datetime',
            'current_period_start' => 'datetime',
            'current_period_end' => 'datetime',
            'pending_effective_at' => 'datetime',
            // When the customer asked for the contract to end, and when it
            // actually did. Two facts, not one: a cancelled package is still a
            // paying customer until the term they bought runs out, so the first
            // is stamped the moment they confirm and the second only when Stripe
            // reports the subscription gone.
            'cancel_requested_at' => 'datetime',
            'cancelled_at' => 'datetime',
            // The fourteen days a consumer may change their mind in, and what
            // happened inside them. See App\Services\Billing\WithdrawalRight,
            // which is the one place that decides whether the window is open.
            'withdrawal_ends_at' => 'datetime',
            // DEAD as a gate, kept as a record — see Order, which carries the
            // same column for the same reason. A withdrawing consumer now gets
            // the whole amount back whatever this says.
            'immediate_start_consent_at' => 'datetime',
            'withdrawn_at' => 'datetime',
            'withdrawal_refund_cents' => 'integer',
            'stripe_event_at' => 'datetime',
            'stripe_price_synced_at' => 'datetime',
            // The swap that has not reached Stripe: the price it was meant to
            // move to, the proration behaviour its direction chose, the error
            // and how often it has been tried. Null once Stripe agrees with us.
            'stripe_price_sync' => 'array',
            // The module items that never reached Stripe: the error, when it
            // failed and how often it has been tried. Null once Stripe bills
            // exactly the modules this contract holds.
            'stripe_addon_sync' => 'array',
            'granted_at' => 'datetime',
            'granted_until' => 'datetime',
            'catalogue_price_cents' => 'integer',
        ];
    }

    public function uniqueIds(): array
    {
        return ['uuid'];
    }

    public function customer(): BelongsTo
    {
        return $this->belongsTo(Customer::class);
    }

    /** The operator who gave this away, when it was a grant. */
    public function grantedBy(): BelongsTo
    {
        return $this->belongsTo(Operator::class, 'granted_by');
    }

    /**
     * Whether this contract's own terms were set by a grant rather than a
     * purchase — the one fact Revenue and IssueInvoice both key off.
     */
    public function isGranted(): bool
    {
        return $this->granted_at !== null;
    }

    /** A full gift: nothing was charged for it at all. */
    public function isFreeGrant(): bool
    {
        return $this->isGranted() && (int) $this->price_cents === 0;
    }

    public function order(): BelongsTo
    {
        return $this->belongsTo(Order::class);
    }

    /** Which version of the plan this contract was sold under. */
    public function planVersion(): BelongsTo
    {
        return $this->belongsTo(PlanVersion::class, 'plan_version_id');
    }

    /** Modules booked onto this contract, each frozen at its booked price. */
    public function addons(): HasMany
    {
        return $this->hasMany(SubscriptionAddon::class);
    }

    /** Every commercial event this contract has been through. */
    public function records(): HasMany
    {
        return $this->hasMany(SubscriptionRecord::class);
    }

    public function instance(): BelongsTo
    {
        return $this->belongsTo(Instance::class);
    }

    /** Whether the catalogue still sells this plan at all. */
    public static function knowsPlan(string $plan): bool
    {
        return app(PlanCatalogue::class)->isSellable($plan);
    }

    /** The single currency every catalogue price is expressed in. */
    public static function catalogueCurrency(): string
    {
        return strtoupper((string) config('provisioning.currency', 'EUR'));
    }

    /**
     * Take the version on sale right now and freeze it onto a new subscription.
     *
     * Reads the catalogue tables, never config: there is one source, and it is
     * the one the owner can actually edit. An unknown, withdrawn or unpriced
     * plan throws rather than returning something plausible — a contract opened
     * from a guess is worse than a purchase that visibly fails.
     */
    public static function snapshotFrom(string $plan, string $term = self::TERM_MONTHLY, ?int $versionId = null): array
    {
        // A typo would otherwise be priced monthly while carrying an unknown
        // term — a subscription whose price and billing period disagree.
        if (! in_array($term, [self::TERM_MONTHLY, self::TERM_YEARLY], true)) {
            throw new RuntimeException("Unknown term: {$term}");
        }

        // Honour the version the customer was actually shown, even if it has
        // stopped being sold since — a checkout that completes just after a
        // scheduled transition must not contract them to terms they never saw.
        $version = $versionId !== null
            ? app(PlanCatalogue::class)->soldVersion($plan, $versionId)
            : app(PlanCatalogue::class)->currentVersion($plan);
        $price = $version->priceFor($term);

        if ($price === null) {
            throw new RuntimeException("Plan '{$plan}' is not sold on a {$term} term.");
        }

        return [
            'plan' => $plan,
            // Which version this contract was sold under. A historical
            // reference resolved by plan NAME would hand back today's terms.
            'plan_version_id' => $version->id,
            'term' => $term,
            'price_cents' => $price->amount_cents,
            'currency' => $price->currency,
            'quota_gb' => $version->quota_gb,
            'traffic_gb' => $version->traffic_gb,
            'seats' => $version->seats,
            'ram_mb' => $version->ram_mb,
            'cores' => $version->cores,
            'disk_gb' => $version->disk_gb,
            'performance' => $version->performance,
            // Copied so provisioning never has to ask the catalogue what to
            // clone. Not in FROZEN — see the migration for why.
            'template_vmid' => $version->template_vmid,
            // Frozen too: which direction a later change goes must not depend
            // on where the plan sits in today's catalogue.
            'tier' => (int) $version->family->tier,
        ];
    }

    /**
     * Move this contract onto another package — the one sanctioned exception to
     * the freeze above.
     *
     * The freeze exists so that nothing can rewrite what a customer is owed
     * behind their back: a price rise, an edited plan, a stray `update()`. A
     * plan change is the opposite of that. It is the customer asking for
     * different terms, it is priced and recorded before it lands, and afterwards
     * the contract has to state the package they are actually on — otherwise the
     * snapshot stops being the authority every step and every bill reads it as.
     *
     * So the exception is named and lives here rather than being a `saveQuietly()`
     * somewhere in an action, where it would read as a way around the guard
     * instead of a decision. Exactly one caller: App\Actions\ApplyPlanChange,
     * which refuses unless PlanChange says the move is allowed today and writes
     * the proof-register row in the same transaction.
     *
     * Only the snapshot's own columns move. `started_at` stays where it is — the
     * contract began when it began, and a change of package does not restart it
     * — and so do the period boundaries, which belong to the billing cycle and
     * not to the package.
     *
     * @param  array<string, mixed>  $snapshot  from snapshotFrom()
     */
    public function applyPlanSnapshot(array $snapshot): void
    {
        $movable = array_diff([...self::FROZEN, 'template_vmid'], ['started_at']);

        $this->forceFill(array_intersect_key($snapshot, array_flip($movable)));

        // Quietly, because the guard in booted() is what is being excepted here.
        $this->saveQuietly();

        // The version has moved, so anything already holding the old one is
        // holding the wrong package — the proof-register row written straight
        // after this reads planVersion for the version number it files under.
        $this->unsetRelation('planVersion');
    }

    /**
     * Book a change of package for the end of the term the customer has already
     * paid for.
     *
     * The date is stamped, not derived. `current_period_end` is where it comes
     * FROM, but it is not where it can be READ from afterwards: Stripe pushes
     * that column forward on every renewal, so a downgrade whose due date was
     * re-read from it would be deferred by another whole term each time the
     * customer was billed — for the package they had asked to leave. Booked once
     * means booked once, on the date it was booked for.
     *
     * One at a time, deliberately. Choosing another package replaces the
     * booking rather than queueing behind it, exactly as the cart replaces the
     * pending order: two scheduled changes contradict each other and nothing
     * could resolve which one the customer meant.
     */
    public function bookPendingPlanChange(string $plan): void
    {
        $this->update([
            'pending_plan' => $plan,
            'pending_effective_at' => $this->current_period_end?->copy(),
        ]);
    }

    /**
     * Drop a booked change: the customer withdrew it, moved the other way, or
     * it has just landed.
     *
     * Always both columns. A plan with no date is a change that can never come
     * due, and a date with no plan is a contract that says it is going to shrink
     * into nothing.
     */
    public function clearPendingPlanChange(): void
    {
        if ($this->pending_plan === null && $this->pending_effective_at === null) {
            return;
        }

        $this->update(['pending_plan' => null, 'pending_effective_at' => null]);
    }

    /** Is a change booked on this contract that has not landed yet? */
    public function hasPendingPlanChange(): bool
    {
        return $this->pending_plan !== null && $this->pending_effective_at !== null;
    }

    /**
     * When the withdrawal window opened: the moment the contract was concluded.
     *
     * `started_at` and not `created_at`, and the two are the same instant today
     * — but `started_at` is in FROZEN and therefore cannot be moved by anything,
     * which is the property a statutory deadline needs.
     */
    public function withdrawalOpenedAt(): ?Carbon
    {
        return $this->started_at;
    }

    /** Has this contract already been withdrawn from? */
    public function isWithdrawn(): bool
    {
        return $this->withdrawn_at !== null;
    }

    /**
     * Is this contract over — cancelled at the end of its term, withdrawn from,
     * or deleted at Stripe?
     *
     * `status` and not `cancel_requested_at`: a customer who has cancelled keeps
     * everything they paid for until the term runs out, and treating them as
     * gone the moment they clicked would take away service they are owed. Only
     * `cancelled` is the end, and it is written by whichever of the three got
     * there — see ApplyStripeBillingEvent::subscriptionDeleted().
     */
    public function hasEnded(): bool
    {
        return $this->status === 'cancelled';
    }

    public function isYearly(): bool
    {
        return $this->term === self::TERM_YEARLY;
    }

    /**
     * What this contract works out to per month.
     *
     * `price_cents` is the price of a TERM, and a yearly term holds the whole
     * year. Everything that prints a monthly figure — the plan card, the cloud
     * page, the revenue column — has to divide, and every one of them getting
     * it right separately is not something to rely on.
     */
    public function monthlyPriceCents(): int
    {
        return $this->isYearly()
            ? (int) round($this->price_cents / 12)
            : (int) $this->price_cents;
    }

    /**
     * The whole monthly bill, net: the plan plus every module still booked.
     *
     * All of it frozen — that is the owner's rule. A total assembled from the
     * contract for the plan and from today's catalogue for the modules would
     * protect half a customer's price and quietly re-price the other half.
     */
    public function totalMonthlyCents(): int
    {
        return $this->monthlyPriceCents() + $this->addons
            ->where('cancelled_at', null)
            ->sum(fn (SubscriptionAddon $addon) => $addon->monthlyCents());
    }

    /**
     * What a whole TERM of this contract is agreed at, net: the package plus
     * every module still booked, each for the same term.
     *
     * The figure a renewal has to be held against. Stripe bills a cycle as the
     * package item plus one item per module, so what it takes is the gross of
     * ALL of them — and the proof register was comparing that against
     * `price_cents` alone. Every renewal on a contract with a single module was
     * therefore filed as charged at the wrong amount, which is the one thing the
     * register's flag exists to point at.
     *
     * Per term rather than per month because that is what a cycle bills: a yearly
     * contract renews a year of the package and a year of each module beside it —
     * see App\Services\Billing\AddonPrices, which is where the Price those items
     * bill on gets its amount.
     */
    public function termNetCents(): int
    {
        return (int) $this->price_cents + $this->addons
            ->where('cancelled_at', null)
            ->sum(fn (SubscriptionAddon $addon) => AddonPrices::termNetCents($addon->monthlyCents(), (string) $this->term));
    }
}
