<?php

namespace App\Services\Billing;

use App\Models\Instance;
use App\Models\Subscription;

/**
 * How much a customer may actually store: the package's allowance plus every
 * storage pack they have booked.
 *
 * The packs were sold and never delivered. `AddonCatalogue::STORAGE` reached
 * `subscription_addons` — priced, frozen, charged for every month — and stopped
 * there: Nextcloud's quota was written from `instances.quota_gb`, which is the
 * package alone, so a customer could pay ten euros a month for a hundred
 * gigabytes that no part of the product ever gave them. This is the one place
 * that adds the two together, and everything that needs to know the figure —
 * the quota step, the downgrade check, the portal, the console — asks HERE.
 * Nothing re-derives it from `quota_gb`.
 *
 * **Derived, not stored, and deliberately so.** A column would have to be
 * rewritten by every path that can move either half of the sum: booking a pack,
 * cancelling one, an operator's grant, a grant expiring, a plan change, the
 * contract being replaced. The day one of those paths forgets, the column is
 * silently wrong in whichever direction is worse — a customer charged for
 * storage they cannot use, or one still holding storage they have stopped paying
 * for — and it would be wrong invisibly, because a column looks authoritative.
 * A sum of two rows that are already the authority cannot drift from them.
 *
 * `instances.quota_applied_gb` is NOT the same thing and is not duplicated here.
 * That column records what the guest was last actually TOLD, which is the only
 * way to tell a machine whose allowance is enforced from one where the figure
 * has only ever been a row in our database. This class answers what the customer
 * is OWED; the two disagreeing is exactly the signal that column exists for.
 */
final readonly class StorageAllowance
{
    private function __construct(
        /** What the package itself includes. */
        public int $planGb,
        /** How many extra-storage packs are booked and still running. */
        public int $packs,
        /** How big one pack is today — see packSizeGb() for why "today". */
        public int $packSizeGb,
    ) {}

    /**
     * What the customer behind this machine may store.
     *
     * The machine's own row first and the contract only as a fallback: the
     * instance is what provisioning wrote and what the host's storage accounting
     * was sized from, while a contract exists before there is a machine at all.
     */
    public static function for(?Instance $instance): self
    {
        if ($instance === null) {
            return self::forPlan(0, null);
        }

        // The link when provisioning has written it, and otherwise the
        // customer's live contract — the same resolution the portal and the
        // console's grant screen make, asked of the one class that answers it.
        $contract = $instance->subscription
            ?? app(CustomDomainAccess::class)->contractOf($instance->customer);

        return self::forPlan((int) ($instance->quota_gb ?? $contract?->quota_gb ?? 0), $contract);
    }

    /**
     * The same question about a package the customer is not on yet.
     *
     * The downgrade check needs it: whether a smaller package is big enough
     * depends on the packs the customer has ALREADY paid for, which move with
     * them.
     */
    public static function forPlan(int $planGb, ?Subscription $subscription): self
    {
        return new self(max(0, $planGb), self::bookedPacks($subscription), self::packSizeGb());
    }

    /** Everything the booked packs add. */
    public function packGb(): int
    {
        return $this->packs * $this->packSizeGb;
    }

    /** The figure Nextcloud is told, the disk is sized for, and the portal shows. */
    public function totalGb(): int
    {
        return $this->planGb + $this->packGb();
    }

    public function hasPacks(): bool
    {
        return $this->packs > 0;
    }

    /**
     * How many MORE packs it would take to cover this many bytes.
     *
     * Zero when the allowance already covers it. Rounded up, because half a pack
     * is not for sale and an answer that leaves the customer one gigabyte short
     * is an answer that sends them back a second time.
     */
    public function packsToCover(int $bytes): int
    {
        $short = $bytes - $this->totalGb() * 1024 ** 3;

        if ($short <= 0 || $this->packSizeGb <= 0) {
            return 0;
        }

        return (int) ceil($short / ($this->packSizeGb * 1024 ** 3));
    }

    /**
     * Every pack still running on this contract, counted by quantity.
     *
     * Queried rather than read off a loaded relation: this is asked immediately
     * after a pack is booked or cancelled, and a relation loaded before that
     * would answer with the state the caller has just changed.
     */
    private static function bookedPacks(?Subscription $subscription): int
    {
        if ($subscription === null) {
            return 0;
        }

        return (int) $subscription->addons()->active()
            ->where('addon_key', AddonCatalogue::STORAGE)
            ->sum('quantity');
    }

    /**
     * What one pack is worth, from the catalogue.
     *
     * Read live rather than frozen onto the booking, unlike the PRICE. That is
     * not an oversight but it is a limit worth naming: `subscription_addons`
     * freezes what the customer agreed to PAY, and if the owner ever re-cuts the
     * pack from 100 GB to 200 GB, existing bookings would grow with it. Today
     * there is one pack size and it has never moved; the day it does, the size
     * belongs on the booking beside the price.
     */
    private static function packSizeGb(): int
    {
        return max(0, (int) config('provisioning.storage_addon.gb', 0));
    }
}
