<?php

namespace App\Services\Billing;

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

/**
 * What the modules cost today, and what THIS customer pays for the ones they
 * have already booked.
 *
 * Two different questions, and the whole point of Phase 4 is that they have
 * different answers. A booked module is a price the customer agreed to; an
 * unbooked one is a sale we have not made yet, at whatever it costs now.
 *
 * Storage and the recurring modules live in config/provisioning.php. Unlike the
 * plans, they were never versioned or scheduled, so moving them into tables
 * would buy nothing — the freezing that matters happens on
 * `subscription_addons`, which is where a customer's own price lives.
 *
 * It is also where the third question about a module is answered: what a SECOND
 * booking of it means. Storage is sold in packs and two of them are twice as
 * much storage; the rest are things a contract either has or has not, and a
 * second one of those is a second charge for the first. That is declared per
 * module beside its price (`sold_as`) and read from here by everything that
 * books, offers or grants one — see soldAs() and duplicateRefusal().
 */
final class AddonCatalogue
{
    /** The extra-storage pack is priced separately from the module list. */
    public const STORAGE = 'storage';

    /** Sold in packs: booking another one gives the customer more of it. */
    public const QUANTITY = 'quantity';

    /** Had or not had: booking another one gives the customer nothing. */
    public const ENTITLEMENT = 'entitlement';

    /** Today's monthly net price for one unit, or null if we do not sell it. */
    public function priceCents(string $key): ?int
    {
        $price = $this->definition($key)['price_cents'] ?? null;

        return $price === null ? null : (int) $price;
    }

    /**
     * How big a pack is that someone buys TODAY.
     *
     * Only for new bookings. What an already-bought pack weighs lives on the
     * booking itself — see StorageAllowance.
     */
    public function packGb(): int
    {
        return (int) config('provisioning.storage_addon.gb', 0);
    }

    /**
     * What a pack takes up on the host: the usable gigabytes plus the
     * headroom a stacked pack needs too. Defaults to the usable figure when
     * none is configured — that was the state before headroom existed.
     */
    public function packDiskGb(): int
    {
        return (int) config('provisioning.storage_addon.disk_gb', $this->packGb());
    }

    public function knows(string $key): bool
    {
        return $this->priceCents($key) !== null;
    }

    /**
     * How this module is sold — see the `sold_as` block in
     * config/provisioning.php for what each answer means commercially.
     *
     * A module that declares nothing counts as an entitlement, which is the
     * cheaper of the two mistakes: a lost sale can be made again tomorrow, a
     * customer charged twice for one thing has to be found and refunded. The
     * declaration is not optional all the same — AddonEntitlementTest fails on
     * a module that ships without one, so the default is a safety net rather
     * than a way of leaving the decision unmade.
     */
    public function soldAs(string $key): string
    {
        return ($this->definition($key)['sold_as'] ?? null) === self::QUANTITY
            ? self::QUANTITY
            : self::ENTITLEMENT;
    }

    /** May a contract hold only one of these at a time? */
    public function isEntitlement(string $key): bool
    {
        return $this->soldAs($key) === self::ENTITLEMENT;
    }

    /** Is this module booked and still running on this contract? */
    public function hasBooked(?Subscription $subscription, string $key): bool
    {
        return $subscription !== null
            && $subscription->addons()->active()->where('addon_key', $key)->exists();
    }

    /**
     * Why this module may not be booked onto this contract AGAIN. Null when it
     * may.
     *
     * The one place the rule is written down, in the sentence the customer is
     * shown: the booking action refuses with it, and the portal asks it before
     * offering the button, so nobody is invited to buy something we would then
     * turn down. A quantity module never refuses — a second storage pack is a
     * second hundred gigabytes, and that is the whole point of selling it in
     * packs.
     */
    public function duplicateRefusal(?Subscription $subscription, string $key): ?string
    {
        if (! $this->isEntitlement($key) || ! $this->hasBooked($subscription, $key)) {
            return null;
        }

        return __('billing.addon_already_booked', ['module' => $this->name($key)]);
    }

    /**
     * Warum es dieses Modul für dieses Paket gar nicht gibt. Null, wenn es
     * das gibt.
     *
     * Nicht dasselbe wie "schon gebucht" oder "Deckel erreicht": hier ist das
     * Modul auf diesem Paket nicht vorgesehen. Genannt wird das Paket beim
     * Schlüssel, wie bei der eigenen Domain — der Katalog beschreibt, was ein
     * Paket HAT, nicht was es kaufen darf.
     */
    public function availabilityRefusal(?Subscription $subscription, string $key): ?string
    {
        $closed = (array) ($this->definition($key)['unavailable_on'] ?? []);

        if ($subscription === null || ! in_array((string) $subscription->plan, $closed, true)) {
            return null;
        }

        return __('billing.addon_not_available', ['module' => $this->name($key)]);
    }

    /** Wie viele Einheiten ein Vertrag von diesem Modul halten darf, oder null. */
    public function maxQuantity(string $key): ?int
    {
        $max = $this->definition($key)['max'] ?? null;

        return $max === null ? null : max(1, (int) $max);
    }

    /**
     * Wie viele davon dieser Vertrag jetzt noch buchen könnte.
     *
     * BEIDE Gründe, aus denen BookAddon eine Buchung abweist, nicht nur der
     * Deckel: ein Modul, das es für dieses Paket gar nicht gibt, ist null Mal
     * buchbar und nicht drei Mal. Vorher antwortete diese Methode einem
     * Enterprise-Vertrag „3" auf die Frage, die ihr eigener Docblock stellt —
     * eine Zahl, die die Aktion dahinter jedes Mal abgelehnt hätte, und ein
     * Aufrufer, der sie einem Kunden zeigt, verspricht etwas Unmögliches.
     */
    public function bookableQuantity(?Subscription $subscription, string $key): int
    {
        if ($this->availabilityRefusal($subscription, $key) !== null) {
            return 0;
        }

        $max = $this->maxQuantity($key);

        if ($max === null) {
            return PHP_INT_MAX;
        }

        return max(0, $max - $this->bookedQuantity($subscription, $key));
    }

    /**
     * Warum diese Menge nicht gebucht werden darf. Null, wenn sie darf.
     *
     * In dem Satz, den der Kunde liest — die Aktion weist damit ab, und die
     * Ansicht fragt damit, bevor sie einen Knopf anbietet. Zwei Formulierungen
     * derselben Grenze wären zwei Grenzen.
     */
    public function quantityRefusal(?Subscription $subscription, string $key, int $quantity): ?string
    {
        $max = $this->maxQuantity($key);

        if ($max === null || $this->bookedQuantity($subscription, $key) + $quantity <= $max) {
            return null;
        }

        return __('billing.addon_limit_reached', ['module' => $this->name($key), 'max' => $max]);
    }

    /** Wie viele Einheiten dieses Moduls auf dem Vertrag laufen. */
    private function bookedQuantity(?Subscription $subscription, string $key): int
    {
        if ($subscription === null) {
            return 0;
        }

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

    /** The module's name as the customer reads it, never its key. */
    public function name(string $key): string
    {
        return $key === self::STORAGE
            ? __('billing.storage_title')
            : __('billing.addon.'.$key.'.name');
    }

    /**
     * One module's entry, whichever side of the catalogue it lives on.
     *
     * Storage is priced apart from the module list — it is the pack everything
     * else is not — but every question asked of a module has to be answerable
     * about it too, so the two shapes are resolved here once instead of at each
     * caller.
     *
     * @return array<string, mixed>
     */
    private function definition(string $key): array
    {
        if ($key === self::STORAGE) {
            return (array) config('provisioning.storage_addon');
        }

        $addon = (array) config("provisioning.addons.{$key}");

        return $addon === [] ? [] : $addon + ['price_cents' => 0];
    }

    /**
     * Every module we sell, each answered for this customer: booked ones at
     * the price they were booked at, the rest at today's.
     *
     * A module can be booked more than once — storage is sold in packs, and two
     * orders can each carry one — and each booking keeps its own price. So the
     * bookings are summed rather than reduced to one row: showing a single
     * arbitrary price while charging for all of them would let the page and the
     * bill say different things.
     *
     * @return array<string, array{key: string, price_cents: ?int, monthly_cents: int, booked: bool, entitlement: bool, quantity: int, cancelling: bool, cancels_at: ?Carbon, bookings: array<int, array{uuid: string, price_cents: int, quantity: int, cancels_at: ?Carbon}>}>
     */
    public function forSubscription(?Subscription $subscription): array
    {
        $booked = $subscription?->addons()->active()->orderBy('id')->get()->groupBy('addon_key')
            ?? collect();

        $keys = array_merge(array_keys((array) config('provisioning.addons')), [self::STORAGE]);

        $rows = [];

        foreach ($keys as $key) {
            $own = $booked->get($key) ?? collect();
            $prices = $own->pluck('price_cents')->unique();

            $rows[$key] = [
                'key' => $key,
                // Their price if they have exactly one, today's if they have
                // none — and null when their bookings disagree, because there
                // is no single honest figure to print.
                'price_cents' => match (true) {
                    $own->isEmpty() => $this->priceCents($key),
                    $prices->count() === 1 => (int) $prices->first(),
                    default => null,
                },
                'monthly_cents' => (int) $own->sum(fn ($addon) => $addon->monthlyCents()),
                'booked' => $own->isNotEmpty(),
                // Whether booking it again would be a second helping or a second
                // charge for the first one. The card reads this to decide
                // whether it may still offer the module, so the page and
                // BookAddon answer from the same declaration.
                'entitlement' => $this->isEntitlement($key),
                // A granted module shows without a price too, the same rule as
                // a granted plan — "ein Plugin schenken" should not read as
                // "kostenlos" on the very page that sells it to everyone else.
                'granted' => $own->isNotEmpty() && $own->every(fn ($addon) => $addon->isGranted()),
                'quantity' => (int) $own->sum('quantity'),
                // A cancellation that has been booked and not yet kept. The
                // module is still running and still on the bill, which is why
                // `booked` above stays true — what changes is that the card has
                // to say until when, and offer the way back rather than the way
                // out. Only when EVERY running booking is on its way out: a
                // customer holding two packs who has cancelled one has not
                // cancelled the module.
                'cancelling' => $own->isNotEmpty() && $own->every(
                    fn (SubscriptionAddon $addon) => $addon->endsAtPeriodEnd()
                ),
                // The earliest of them, because that is the first day something
                // the customer can see actually changes.
                'cancels_at' => $own->pluck('cancels_at')->filter()->min(),
                'bookings' => $own->map(fn ($addon) => [
                    'uuid' => $addon->uuid,
                    'price_cents' => $addon->price_cents,
                    'quantity' => $addon->quantity,
                    'cancels_at' => $addon->cancels_at,
                ])->values()->all(),
            ];
        }

        return $rows;
    }
}
