<?php

namespace App\Livewire\Admin;

use App\Models\PlanFamily;
use App\Models\PlanVersion;
use App\Models\Subscription;
use App\Services\Billing\PlanCatalogue;
use App\Support\LocalTime;
use App\Support\Money;
use Illuminate\Validation\Rule;
use Livewire\Attributes\Layout;
use Livewire\Attributes\On;
use Livewire\Component;
use RuntimeException;

/**
 * The versions of one plan: what it can do, what it costs, and when it sells.
 *
 * The page is built around the one rule that matters here — a draft can be
 * edited freely, a published version cannot be touched at all. So drafting and
 * publishing are separate acts, and publishing says plainly that it is final.
 */
#[Layout('layouts.admin')]
class PlanVersions extends Component
{
    public string $uuid;

    /** The draft being written. */
    public int $quotaGb = 100;

    public int $trafficGb = 1000;

    public int $seats = 5;

    public int $ramMb = 4096;

    public int $cores = 2;

    public int $diskGb = 120;

    public string $performance = 'standard';

    public ?int $templateVmid = 9000;

    /** @var array<int, string> */
    public array $features = [];

    /**
     * In EUROS, as typed. The catalogue stores cents; asking the owner to type
     * them too turned €799 into "79900", where one slipped digit is a factor of
     * ten on an invoice. Money::toCents does the conversion, once.
     */
    // 39,00 €: der Monatspreis von Start auf der neuen Leiter — als Vorbelegung
    // für die allererste Version einer neuen Familie, die noch keine eigene hat.
    public string $monthlyPrice = '39,00';

    /**
     * How many of the twelve months a year costs nothing.
     *
     * The yearly TOTAL used to be typed here as a second free-form figure, and
     * nothing then knew why 588 belonged to a package costing 49 — so no page
     * could say "two months free" without somebody working it out again by hand.
     * The total is derived from this (see yearlyCents), which is also the only
     * arrangement in which the sentence a customer reads and the amount Stripe
     * charges cannot drift apart.
     */
    public int $freeMonths = 2;

    /** Publishing: when it goes on sale, and optionally when it stops. */
    public string $availableFrom = '';

    public string $availableUntil = '';

    public ?string $publishing = null;

    public function mount(string $uuid): void
    {
        $this->authorize('plans.manage');

        $family = PlanFamily::query()->where('uuid', $uuid)->firstOrFail();
        $this->uuid = $uuid;

        // Start the draft from what this plan is today, so the common case —
        // "same plan, new price" — is a single edit rather than nine.
        $latest = $family->versions()->orderByDesc('version')->first();

        if ($latest !== null) {
            $this->quotaGb = $latest->quota_gb;
            $this->trafficGb = $latest->traffic_gb;
            $this->seats = $latest->seats;
            $this->ramMb = $latest->ram_mb;
            $this->cores = $latest->cores;
            $this->diskGb = $latest->disk_gb;
            $this->performance = (string) ($latest->performance ?? 'standard');
            $this->templateVmid = $latest->template_vmid;
            $this->features = $latest->features ?? [];

            $monthly = $latest->priceFor(Subscription::TERM_MONTHLY);
            // 3900 = 39,00 €, dieselbe neue Vorgabe wie oben, für den seltenen
            // Fall, dass die letzte Version keinen Monatspreis trägt.
            $this->monthlyPrice = Money::fromCents($monthly?->amount_cents ?? 3900);
            $this->freeMonths = (int) $latest->free_months;
        }
    }

    /**
     * What a year costs: the months that are paid for, at the monthly price.
     *
     * One place, static, so the preview on the form and the amount written to
     * the catalogue are the same arithmetic rather than two copies of it.
     */
    public static function yearlyCents(int $monthlyCents, int $freeMonths): int
    {
        return $monthlyCents * (12 - max(0, min(12, $freeMonths)));
    }

    private function family(): PlanFamily
    {
        return PlanFamily::query()->where('uuid', $this->uuid)->firstOrFail();
    }

    /** Write a new draft. Nothing is promised until it is published. */
    public function draft(): void
    {
        $this->authorize('plans.manage');

        // Every bound is explicit, and generous rather than tight. A mistyped
        // figure has to come back as a message on the field; without an upper
        // limit it overflows the column instead and the owner gets a 500 with
        // no idea which number was wrong.
        $data = $this->validate([
            'quotaGb' => 'required|integer|min:1|max:1000000',
            'trafficGb' => 'required|integer|min:0|max:10000000',
            'seats' => 'required|integer|min:1|max:100000',
            'ramMb' => 'required|integer|min:512|max:4194304',
            'cores' => 'required|integer|min:1|max:512',
            // The disk has to hold the customer's quota plus the system itself.
            'diskGb' => 'required|integer|min:1|max:1000000|gte:quotaGb',
            // One of the classes we actually have a label for. Free text means
            // a typo is published, frozen, and shown to customers forever as a
            // raw translation key.
            'performance' => ['required', Rule::in(array_keys((array) __('billing.perf')))],
            // Required, not nullable: a version without a blueprint can be
            // published and sold, and then fails provisioning every time.
            'templateVmid' => 'required|integer|min:100|max:999999999',
            'features' => 'array',
            // Same reason as the performance class: a request can carry
            // anything, and an unknown key is frozen at publication and shown
            // to customers as a raw translation key.
            'features.*' => ['string', Rule::in(array_keys((array) __('billing.feature')))],
            // Euros with at most two decimals, either separator. The upper
            // bound lives in Money's pattern: nine whole digits is far past
            // anything we would charge and far short of overflowing the column.
            // Checked as a rule, not afterwards: a price fault has to be
            // reported alongside every other fault on the form, and a check
            // that runs after validate() never runs at all once some other
            // field has already thrown.
            'monthlyPrice' => ['required', 'string', $price = function (string $attribute, mixed $value, callable $fail) {
                if (Money::toCents((string) $value) === null) {
                    $fail(__('plans.price_invalid'));
                }
            }],
            // Nought is a legitimate answer — "a year costs twelve months, pay
            // it in one go" — and the upper bound stops at six, because eleven
            // free months is a typo and not a discount.
            'freeMonths' => 'required|integer|min:0|max:6',
        ]);

        $monthlyCents = Money::toCents($data['monthlyPrice']);
        $yearlyCents = self::yearlyCents((int) $monthlyCents, (int) $data['freeMonths']);

        $version = app(PlanCatalogue::class)->draft(
            $this->family(),
            [
                'quota_gb' => $data['quotaGb'],
                'traffic_gb' => $data['trafficGb'],
                'seats' => $data['seats'],
                'ram_mb' => $data['ramMb'],
                'cores' => $data['cores'],
                'disk_gb' => $data['diskGb'],
                'performance' => $data['performance'],
                'template_vmid' => $data['templateVmid'],
                'features' => array_values($data['features']),
                'free_months' => (int) $data['freeMonths'],
            ],
            // Both terms, because publishing refuses a version that is not
            // priced for each — better to find that out here than in front of
            // a customer.
            [
                Subscription::TERM_MONTHLY => $monthlyCents,
                Subscription::TERM_YEARLY => $yearlyCents,
            ],
        );

        $this->dispatch('notify', message: __('plans.draft_created', ['version' => $version->version]));
    }

    /** Open the publish form for one draft. */
    public function choose(string $versionUuid): void
    {
        $this->publishing = $versionUuid;
        $this->availableFrom = LocalTime::toField(now());
        $this->availableUntil = '';
    }

    public function cancelPublish(): void
    {
        $this->reset('publishing', 'availableFrom', 'availableUntil');
    }

    /**
     * Publish: fix the terms and put them on sale.
     *
     * Everything that can refuse this — an unpriced version, a window that
     * overlaps another — refuses before anything is written, so a rejected
     * publish leaves an editable draft rather than a stranded one.
     */
    public function publish(): void
    {
        $this->authorize('plans.manage');

        $this->validate([
            'availableFrom' => 'required|date',
            'availableUntil' => 'nullable|date|after:availableFrom',
        ]);

        $version = PlanVersion::query()->where('uuid', $this->publishing)->firstOrFail();
        abort_unless($version->plan_family_id === $this->family()->id, 404);

        try {
            app(PlanCatalogue::class)->publish(
                $version,
                LocalTime::fromField($this->availableFrom),
                LocalTime::fromField($this->availableUntil),
            );
        } catch (RuntimeException $e) {
            $this->addError('availableFrom', $e->getMessage());

            return;
        }

        $this->cancelPublish();
        $this->dispatch('notify', message: __('plans.published', ['version' => $version->version]));
    }

    /**
     * Close a running version's window — the ordinary way to take a plan off
     * sale for good, since a published version can never be deleted.
     */
    public function close(string $versionUuid): void
    {
        $this->authorize('plans.manage');

        $version = PlanVersion::query()->where('uuid', $versionUuid)->firstOrFail();
        abort_unless($version->plan_family_id === $this->family()->id, 404);

        try {
            app(PlanCatalogue::class)->schedule($version, $version->available_from, now());
        } catch (RuntimeException $e) {
            $this->addError('availableFrom', $e->getMessage());

            return;
        }

        $this->dispatch('notify', message: __('plans.closed', ['version' => $version->version]));
    }

    /**
     * Put a closed version back on sale — the way out of a closed window.
     *
     * The likely refusal here is that a newer version has taken over, and the
     * catalogue says so in English, at the length a log entry deserves. Which
     * of its two refusals it was is read off the version itself rather than out
     * of the message text: a message is prose, and prose changes.
     */
    public function reopen(string $versionUuid): void
    {
        $this->authorize('plans.manage');

        $version = PlanVersion::query()->where('uuid', $versionUuid)->firstOrFail();
        abort_unless($version->plan_family_id === $this->family()->id, 404);

        try {
            app(PlanCatalogue::class)->reopen($version);
        } catch (RuntimeException) {
            $this->addError('availableFrom', __(
                $version->isPublished() ? 'plans.reopen_blocked' : 'plans.reopen_draft'
            ));

            return;
        }

        $this->dispatch('notify', message: __('plans.reopened', ['version' => $version->version]));
    }

    #[On('plan-draft-deleted')]
    public function refresh(): void
    {
        // Livewire re-renders; the listener exists so the modal can say so.
    }

    /**
     * The version that publishing at the chosen moment would take off sale.
     *
     * Announced on the form, because publishing now ends a running version's
     * sale instead of being refused, and the owner should not have to work that
     * out from the version list afterwards. Asked of the catalogue rather than
     * worked out here, so the announcement and the act cannot name different
     * versions.
     *
     * @return array{version: int, at: string}|null
     */
    private function handover(PlanFamily $family): ?array
    {
        if ($this->publishing === null) {
            return null;
        }

        $version = $family->versions()->where('uuid', $this->publishing)->first();
        $from = LocalTime::fromField($this->availableFrom);

        if ($version === null || $from === null) {
            return null;
        }

        $predecessor = app(PlanCatalogue::class)->predecessorAt($version, $from);

        return $predecessor === null ? null : [
            'version' => $predecessor->version,
            'at' => $from->local()->isoFormat('L LT'),
        ];
    }

    public function render()
    {
        $family = $this->family();
        $now = now();

        return view('livewire.admin.plan-versions', [
            'family' => $family,
            'versions' => $family->versions()->with('prices')->orderByDesc('version')->get(),
            'now' => $now,
            'handover' => $this->handover($family),
            'featureKeys' => array_keys((array) __('billing.feature')),
            'performanceClasses' => (array) __('billing.perf'),
            'currency' => Subscription::catalogueCurrency(),
            // What a year will cost, worked out here so the form shows the same
            // figure it is about to write. Null while the monthly price is not a
            // number yet — the field says so on its own, and a preview of
            // nothing is worse than no preview.
            'yearlyPreview' => ($cents = Money::toCents($this->monthlyPrice)) === null
                ? null
                : self::yearlyCents((int) $cents, $this->freeMonths),
        ]);
    }
}
