<?php

namespace App\Livewire;

use App\Livewire\Concerns\ResolvesCustomer;
use App\Models\Customer;
use App\Models\Order;
use App\Models\Subscription;
use App\Services\Billing\AddonCatalogue;
use App\Services\Billing\CustomDomainAccess;
use App\Services\Billing\DowngradeCheck;
use App\Services\Billing\PlanCatalogue;
use App\Services\Billing\TaxTreatment;
use App\Services\Traffic\TrafficMeter;
use Illuminate\Support\Facades\DB;
use Livewire\Attributes\Layout;
use Livewire\Attributes\On;
use Livewire\Component;

#[Layout('layouts.portal-app')]
class Billing extends Component
{
    use ResolvesCustomer;

    /**
     * Record a purchase intent (order). Fulfillment (Stripe checkout + resize) is
     * mocked for now — the order is created with status 'pending'.
     */
    public function purchase(string $type, ?string $key = null): void
    {
        $customer = $this->requireCustomer();
        if ($customer === null) {
            return;
        }

        $instance = $customer->instances()->latest('id')->first();
        $currentPlan = $instance?->plan ?? 'start';
        $plans = app(PlanCatalogue::class)->sellable();
        $addons = (array) config('provisioning.addons');

        [$plan, $amount, $addonKey] = match ($type) {
            'upgrade', 'downgrade' => [$key, (int) ($plans[$key]['price_cents'] ?? 0), null],
            'storage' => [$currentPlan, (int) config('provisioning.storage_addon.price_cents', 0), null],
            'traffic' => [$currentPlan, (int) config('provisioning.traffic.addon.price_cents', 0), null],
            'addon' => [$currentPlan, (int) ($addons[$key]['price_cents'] ?? 0), $key],
            default => [null, 0, null],
        };

        $access = app(CustomDomainAccess::class);

        // Guard against invalid keys / non-upgrades.
        $valid = match ($type) {
            // By rank, matching PlanChange and the cards on the page. Comparing
            // prices would call a grandfathered plan an upgrade to a smaller
            // one and charge for the privilege.
            'upgrade' => isset($plans[$key])
                && (int) ($plans[$key]['tier'] ?? 0) > (int) ($instance?->subscription?->tier ?? $plans[$currentPlan]['tier'] ?? 0),
            // Re-checked here and not only in the view: the button can be
            // hidden and the action still called — a stale tab, a second
            // window, anyone with the component name. A limit enforced only in
            // markup is not enforced.
            'downgrade' => isset($plans[$key])
                && (int) ($plans[$key]['tier'] ?? 0) < (int) ($instance?->subscription?->tier ?? $plans[$currentPlan]['tier'] ?? 0)
                && DowngradeCheck::for($customer, $instance, $plans[$key], $key)->allowed,
            'storage' => true,
            // Always available: running out of traffic is exactly when someone
            // needs to be able to buy more, whatever plan they are on.
            'traffic' => true,
            // The module list leaves the own domain out where it cannot be
            // booked, and this refuses it there for the same reason the
            // downgrade branch re-checks: the button is markup, and markup is
            // not a limit. BookAddon refuses it a third time, at the moment the
            // money would actually buy something.
            'addon' => isset($addons[$key])
                && ($key !== CustomDomainAccess::ADDON || $access->bookable($access->contractOf($customer))),
            default => false,
        };
        if (! $valid || $plan === null) {
            return;
        }

        $datacenter = $instance?->host?->datacenter
            ?? $customer->orders()->latest('id')->value('datacenter')
            ?? 'fsn';

        // A cart cannot hold two plan changes: they contradict each other and no
        // checkout could resolve which one the customer meant. Choosing another
        // replaces the pending one — add-ons still stack.
        //
        // Replacement and insert run together with the customer row locked:
        // two clicks in flight could otherwise both delete and then both
        // insert, leaving exactly the two upgrades this rule exists to prevent.
        $replaced = DB::transaction(function () use ($customer, $type, $plan, $addonKey, $amount, $datacenter) {
            Customer::query()->whereKey($customer->id)->lockForUpdate()->first();

            // Up and down are the same kind of change and contradict each
            // other just as much, so either replaces a pending one of both.
            $replaced = in_array($type, ['upgrade', 'downgrade'], true)
                ? Order::query()
                    ->where('customer_id', $customer->id)
                    ->where('status', 'pending')
                    ->whereIn('type', ['upgrade', 'downgrade'])
                    ->delete()
                : 0;

            Order::create([
                'customer_id' => $customer->id,
                'plan' => $plan,
                'type' => $type,
                'addon_key' => $addonKey,
                'amount_cents' => $amount,
                'currency' => 'EUR',
                'datacenter' => $datacenter,
                'status' => 'pending',
            ]);

            return $replaced;
        });

        if ($replaced > 0) {
            $this->dispatch('notify', message: __('billing.cart.plan_replaced'));
        }

        $this->dispatch('notify', message: __('billing.purchased'));
    }

    #[On('order-removed')]
    public function orderRemoved(): void
    {
        $this->dispatch('notify', message: __('billing.cart.removed'));
    }

    /**
     * The terms to show as "your plan": the contract if there is one, and only
     * otherwise the catalogue — someone browsing before they have bought.
     *
     * @param  array<string, mixed>  $catalogue
     * @return array<string, mixed>
     */
    private function currentTerms(?Subscription $subscription, array $catalogue): array
    {
        if ($subscription === null) {
            return $catalogue;
        }

        return [
            'tier' => $subscription->tier,
            // Per month: the card says "/ month", and a yearly contract stores
            // the whole year.
            'price_cents' => $subscription->monthlyPriceCents(),
            // The portal shows a granted package without a price at all — not
            // "free", not struck through — so a later conversion to paid does
            // not read as a negotiation.
            'granted' => $subscription->isGranted(),
            'currency' => $subscription->currency,
            'quota_gb' => $subscription->quota_gb,
            'traffic_gb' => $subscription->traffic_gb,
            'seats' => $subscription->seats,
            'performance' => $subscription->performance,
            'features' => $subscription->planVersion?->features ?? ($catalogue['features'] ?? []),
        ];
    }

    /**
     * The module cards, minus the ones this customer cannot be sold.
     *
     * Two things the shop was getting wrong about the own domain, and both of
     * them were offers: a package that already includes it was invited to buy
     * it again, and a package that cannot have one at all was invited to buy
     * something we would then refuse. An offer that would be refused is worse
     * than no offer — the customer clicks it, and finds out from an error
     * message what they should have been told by the page.
     *
     * A module already booked keeps its card whatever the package says, because
     * it is still on the bill and hiding it would hide the charge.
     *
     * @param  array<string, array<string, mixed>>  $rows
     * @return array<string, array<string, mixed>>
     */
    private function offerable(array $rows, ?Customer $customer): array
    {
        $access = app(CustomDomainAccess::class);
        $key = CustomDomainAccess::ADDON;

        if (! array_key_exists($key, $rows)) {
            return $rows;
        }

        $contract = $access->contractOf($customer);
        $rows[$key]['included'] = $access->includedInPlan($contract);

        if (! $rows[$key]['included'] && ! $rows[$key]['booked'] && ! $access->bookable($contract)) {
            unset($rows[$key]);
        }

        return $rows;
    }

    public function render()
    {
        $customer = $this->customer();
        $instance = $customer?->instances()->latest('id')->first();
        $plans = app(PlanCatalogue::class)->sellable();
        $currentKey = $instance?->plan ?? 'start';

        // Rank, not price: a grandfathered plan can cost less than a smaller
        // one does today, and offering that as an "upgrade" would charge a
        // customer immediately for losing resources.
        $currentTier = (int) ($instance?->subscription?->tier ?? $plans[$currentKey]['tier'] ?? 0);

        // Smaller plans, each with the reason it cannot be taken today. Shown
        // WITH the reason rather than hidden: a customer who cannot downgrade
        // needs to know what to delete, and a plan that silently disappears
        // reads as "not offered any more".
        $downgrades = collect($plans)
            ->filter(fn ($p) => (int) ($p['tier'] ?? 0) < $currentTier)
            ->sortByDesc(fn ($p) => (int) ($p['tier'] ?? 0))
            // With the key, not only the terms: what a move costs a customer
            // depends on which package they are moving to, and an entry that
            // does not know its own name cannot be asked about it.
            ->map(fn ($p, $k) => $p + ['check' => DowngradeCheck::for($customer, $instance, $p, $k)])
            ->all();

        $upgrades = collect($plans)
            ->filter(fn ($p) => (int) ($p['tier'] ?? 0) > $currentTier)
            ->keys()->all();

        return view('livewire.billing', [
            'currentKey' => $currentKey,
            // What they HAVE comes from their contract; only what they could
            // BUY comes from the shop. Reading this off the catalogue showed a
            // customer today's price as though it were theirs, and dropped the
            // card entirely once their plan stopped being sold.
            'current' => $this->currentTerms($instance?->subscription, $plans[$currentKey] ?? []),
            'instance' => $instance,
            'plans' => $plans,
            'features' => collect($plans)->map(fn ($p) => $p['features'] ?? [])->all(),
            'upgrades' => $upgrades,
            'downgrades' => $downgrades,
            'storage' => (array) config('provisioning.storage_addon'),
            'trafficAddon' => (array) config('provisioning.traffic.addon'),
            // Resolved once: the cart and the plan cards must not state
            // different tax treatments on the same page.
            'tax' => TaxTreatment::for($customer),
            'trafficMeter' => $instance !== null ? TrafficMeter::for($instance) : null,
            // Booked modules at the price they were booked at; everything else
            // at today's. Reading them all off the catalogue would re-price
            // half a customer's bill behind their back.
            'addons' => $this->offerable(
                collect(app(AddonCatalogue::class)->forSubscription($instance?->subscription))
                    ->except(AddonCatalogue::STORAGE)
                    ->all(),
                $customer,
            ),
            'totalMonthlyCents' => $instance?->subscription?->totalMonthlyCents(),
            'pending' => $customer
                ? $customer->orders()->where('status', 'pending')->latest('id')->get()
                : collect(),
        ]);
    }
}
