<?php

namespace App\Services\Billing;

use App\Models\Customer;
use App\Models\Instance;
use App\Models\InstanceMetric;
use App\Models\Seat;
use App\Support\Bytes;

/**
 * Whether a customer may move down to a smaller plan, and if not, why.
 *
 * A downgrade is not the mirror of an upgrade. Going up always fits; going
 * down can ask an instance to hold more than the target plan allows, and the
 * failure has to be explained in the customer's own numbers — "you have 31
 * users, Start allows 10" — rather than as a disabled button. A greyed-out
 * control that does not say what is in the way is the thing people ring about.
 *
 * Checked against what is MEASURED, not what was sold: a customer sitting on
 * 480 GB of a 500 GB plan cannot move to a 200 GB one, whatever their contract
 * says the allowance is.
 *
 * Blockers are not the only thing worth saying beforehand. A downgrade can be
 * perfectly possible and still take something away — the customer's own domain
 * — and being told that afterwards is the same failure as a greyed-out button
 * with no reason on it. So a consequence is reported alongside, in the same
 * shape and in the same place, and it does not make the move impossible: it is
 * the sentence somebody reads before they agree to it.
 *
 * A blocker on storage carries the way OUT of it as well, because "no" without a
 * way out is where this check used to stop. There are exactly two ways out and
 * both are real: buy enough storage packs that the smaller package plus the
 * packs covers what is stored — the packs count towards the limit here, on the
 * same authority everything else reads — or delete data and ask again against a
 * fresh reading rather than last night's.
 *
 * "Both are real" is a promise, and it has to be kept against the COMMERCIAL
 * ceiling and not only against the arithmetic. Packs are capped per contract and
 * are not sold on every package at all, so the number that closes the gap on
 * paper is not always a number anyone may book — and once a pack shrank from 100
 * GB usable to 20, every overage above three packs' worth landed there. The
 * remedy therefore offers packs only when the packs a contract may ACTUALLY book
 * would clear the blocker, and otherwise offers none and says how much would
 * still be left over. An offer that BookAddon would refuse, or that costs money
 * every month and leaves the downgrade blocked anyway, is not a way out — it is
 * the greyed-out button again, wearing a price tag.
 */
final readonly class DowngradeCheck
{
    private function __construct(
        public bool $allowed,
        /** @var array<int, array{key: string, current: string, limit: string}> */
        public array $blockers,
        /** @var array<int, array{key: string, replace: array<string, string>}> */
        public array $consequences = [],
        /**
         * The numbers behind a storage blocker, or null when storage is not what
         * is in the way. Real figures, never a shrug: what is stored, what the
         * target package plus the packs already booked would allow, how much has
         * to go, and how many further packs would cover it instead.
         *
         * `packs` is what this contract may buy AND what would actually lift the
         * blocker — zero when no bookable number of packs does both, and then
         * `short` carries what would still be over after every pack the contract
         * could book. The two are exclusive on purpose: `short` being set is the
         * signal that buying is not one of the ways out here, and the card reads
         * it to say so instead of offering a button.
         *
         * @var array{used: string, limit: string, over: string, packs: int, pack_gb: int, short: ?string}|null
         */
        public ?array $storageRemedy = null,
    ) {}

    /**
     * @param  array<string, mixed>  $target  the catalogue entry being moved to
     * @param  ?string  $targetPlan  its plan key — null when a caller is checking raw
     *                               limits rather than a package, and then the
     *                               consequences that depend on which package it is
     *                               cannot be computed and are not guessed at
     */
    public static function for(Customer $customer, ?Instance $instance, array $target, ?string $targetPlan = null): self
    {
        $blockers = [];

        $seats = Seat::query()
            ->where('customer_id', $customer->id)
            ->whereIn('status', ['active', 'invited'])
            ->count();

        $seatLimit = (int) ($target['seats'] ?? 0);

        if ($seatLimit > 0 && $seats > $seatLimit) {
            $blockers[] = [
                'key' => 'seats',
                'current' => (string) $seats,
                'limit' => (string) $seatLimit,
            ];
        }

        // Asked, never re-derived: which packages may carry an own domain is
        // CustomDomainAccess's question, and this class only reports what it
        // answers about the move being considered.
        $access = app(CustomDomainAccess::class);
        $contract = $access->contractOf($customer);

        // Storage is checked against the last real reading. Falling back to the
        // contractual allowance would refuse a downgrade to anyone who has ever
        // bought a large plan, however empty their instance is.
        //
        // What it is checked AGAINST is the target package plus the storage packs
        // the customer has already booked, because those move with them: a
        // customer on 200 GB with three packs may hold 500 GB, and refusing them
        // the smaller package would be refusing them storage they are paying for.
        $planGb = (int) ($target['quota_gb'] ?? 0);
        $allowance = StorageAllowance::forPlan($planGb, $contract);
        $metric = $instance !== null ? InstanceMetric::latestDisk($instance) : null;
        $storageRemedy = null;

        if ($planGb > 0 && $metric !== null && $metric->disk_used_bytes > $allowance->totalGb() * 1024 ** 3) {
            $blockers[] = [
                'key' => 'storage',
                'current' => Bytes::human($metric->disk_used_bytes),
                'limit' => $allowance->totalGb().' GB',
            ];

            // What closes the gap on paper, and what this contract may actually
            // buy. The second figure is the commercial one and is not re-derived
            // here: AddonCatalogue holds the ceiling and the packages a pack is
            // sold on, and it is the same question BookAddon refuses by — two
            // formulations of one limit would be two limits.
            $wanted = $allowance->packsToCover((int) $metric->disk_used_bytes);
            $bookable = app(AddonCatalogue::class)->bookableQuantity($contract, AddonCatalogue::STORAGE);
            $enough = $wanted <= $bookable;

            $storageRemedy = [
                'used' => Bytes::human($metric->disk_used_bytes),
                'limit' => $allowance->totalGb().' GB',
                'over' => Bytes::human($metric->disk_used_bytes - $allowance->totalGb() * 1024 ** 3),
                'packs' => $enough ? $wanted : 0,
                'pack_gb' => $allowance->packSizeGb,
                // Measured against every pack the contract could still book, not
                // against the ceiling: a customer already holding one pack has a
                // smaller remainder than one holding none, and quoting the
                // ceiling's figure at them would overstate what is left to delete.
                'short' => $enough ? null : Bytes::human(
                    (int) $metric->disk_used_bytes
                        - ($allowance->totalGb() + $bookable * $allowance->packSizeGb) * 1024 ** 3
                ),
            ];
        }

        $consequences = $targetPlan === null ? [] : $access->consequencesOfMovingTo(
            current: $contract,
            instance: $instance,
            targetPlan: $targetPlan,
            targetFeatures: array_values((array) ($target['features'] ?? [])),
        );

        return new self($blockers === [], $blockers, $consequences, $storageRemedy);
    }
}
