<?php

namespace App\Livewire\Admin;

use App\Livewire\Concerns\BuildsRunSteps;
use App\Mail\Transport\MailboxTransport;
use App\Models\Customer;
use App\Models\Host;
use App\Models\Instance;
use App\Models\MonitoringTarget;
use App\Models\ProvisioningRun;
use App\Models\SecurityBlock;
use App\Models\Subscription;
use App\Services\Billing\PlanCatalogue;
use App\Services\Provisioning\HostCapacity;
use App\Support\AdminArea;
use App\Support\MailDelivery;
use App\Support\Readiness;
use App\Support\Settings;
use Illuminate\Support\Carbon;
use Illuminate\Support\Number;
use Livewire\Attributes\Layout;
use Livewire\Component;

/**
 * The console's front page — every figure on it comes from the database.
 *
 * It used to be invented: forty-two customers, €7,842 of monthly revenue, hosts
 * named pve-fsn-1..3, a twelve-month growth curve. It looked like an operating
 * business and reported nothing. Anything without a real source has been
 * removed rather than approximated — the revenue trend line especially, because
 * there is no revenue history to draw it from, and a plausible line is worse
 * than no line.
 */
#[Layout('layouts.admin')]
class Overview extends Component
{
    // current_step is an index into the pipeline, not a name. The provisioning
    // page already owns the translation from one to the other; sharing it keeps
    // the two pages from naming the same step differently.
    use BuildsRunSteps;

    /** A host is presumed missing once it has not checked in for this long. */
    private const HOST_SILENT_AFTER_MINUTES = 30;

    public function render()
    {
        $locale = app()->getLocale();

        $customersOpen = Customer::query()->where('status', '!=', 'closed')->whereNull('closed_at')->count();
        $customersSuspended = Customer::query()->where('status', 'suspended')->count();

        $instancesByStatus = Instance::query()
            ->selectRaw('status, count(*) as n')
            ->groupBy('status')
            ->pluck('n', 'status');

        $hostsByStatus = Host::query()
            ->selectRaw('status, count(*) as n')
            ->groupBy('status')
            ->pluck('n', 'status');

        return view('livewire.admin.overview', [
            'kpis' => [
                [
                    'label' => __('admin.kpi.customers'),
                    'value' => (string) $customersOpen,
                    'sub' => $customersSuspended > 0
                        ? __('admin.kpi.customers_suspended', ['n' => $customersSuspended])
                        : null,
                ],
                [
                    'label' => __('admin.kpi.instances'),
                    'value' => (string) ($instancesByStatus['active'] ?? 0),
                    'sub' => __('admin.kpi.instances_of', ['n' => $instancesByStatus->sum()]),
                ],
                [
                    'label' => __('admin.kpi.hosts'),
                    'value' => (string) ($hostsByStatus['active'] ?? 0),
                    'sub' => __('admin.kpi.hosts_of', ['n' => $hostsByStatus->sum()]),
                ],
                [
                    'label' => __('admin.kpi.mrr'),
                    'value' => $this->monthlyRevenue($locale),
                    'sub' => __('admin.kpi.mrr_sub'),
                ],
            ],
            'newInstances' => $this->newInstancesPerMonth($locale),
            'hostLoad' => $this->hostLoad(),
            'runs' => $this->openRuns(),
            'delivery' => $this->delivery(),
            'notices' => $notices = $this->notices(),
            'noticeCount' => count($notices),
        ]);
    }

    /**
     * What the estate bills per month, net.
     *
     * Off the contracts, not the catalogue: a price rise must not inflate
     * reported revenue for every grandfathered customer overnight. Add-ons
     * included and yearly terms divided — totalMonthlyCents() owns both rules.
     *
     * Grouped by currency and never summed across them. Adding francs to euros
     * produces a number that is wrong in a way nobody notices.
     */
    private function monthlyRevenue(string $locale): string
    {
        $byCurrency = [];

        Subscription::query()
            ->where('status', 'active')
            ->with('addons')
            ->chunkById(200, function ($subscriptions) use (&$byCurrency) {
                foreach ($subscriptions as $subscription) {
                    $currency = strtoupper((string) $subscription->currency);
                    $byCurrency[$currency] = ($byCurrency[$currency] ?? 0) + $subscription->totalMonthlyCents();
                }
            });

        if ($byCurrency === []) {
            return Number::currency(0, in: Subscription::catalogueCurrency(), locale: $locale);
        }

        return collect($byCurrency)
            ->map(fn (int $cents, string $currency) => Number::currency($cents / 100, in: $currency, locale: $locale))
            ->implode(' · ');
    }

    /**
     * Instances created per month over the last twelve.
     *
     * Deliberately "created", not "in operation": reconstructing how many were
     * running at the end of some past month would need a history nobody keeps,
     * and the honest version of that chart is this one with a truthful label.
     *
     * @return array<string, mixed>
     */
    private function newInstancesPerMonth(string $locale): array
    {
        $start = Carbon::now()->startOfMonth()->subMonths(11);

        $counts = Instance::query()
            ->where('created_at', '>=', $start)
            ->get(['created_at'])
            ->countBy(fn (Instance $i) => $i->created_at->local()->format('Y-m'));

        $labels = [];
        $data = [];

        for ($i = 0; $i < 12; $i++) {
            $month = $start->copy()->addMonths($i);
            $labels[] = $month->local()->locale($locale)->isoFormat('MMM');
            $data[] = (int) ($counts[$month->local()->format('Y-m')] ?? 0);
        }

        return [
            'empty' => array_sum($data) === 0,
            'config' => [
                'type' => 'bar',
                'data' => [
                    'labels' => $labels,
                    'datasets' => [[
                        'label' => __('admin.new_instances'),
                        'data' => $data,
                        'backgroundColor' => 'token:accent',
                        'borderRadius' => 3,
                        'maxBarThickness' => 26,
                    ]],
                ],
                'options' => [
                    'scales' => [
                        'x' => ['grid' => ['display' => false]],
                        // Whole instances only — a y-axis offering "1.5 instances"
                        // is the giveaway that a chart was never looked at.
                        'y' => ['beginAtZero' => true, 'ticks' => ['precision' => 0], 'grid' => ['color' => 'token:border']],
                    ],
                    'plugins' => ['legend' => ['display' => false]],
                ],
            ],
        ];
    }

    /**
     * Storage committed on each host, against the capacity placement may use.
     *
     * Deliberately the model's own committedGb()/freeGb()/usedPct() rather than
     * a quicker sum here. Placement counts the VM disk allocation, ignores a
     * failed instance that never got a VM, and subtracts the host's reserve —
     * a dashboard doing its own arithmetic would show a host as comfortable
     * while placement was already refusing to put anything on it, and the
     * disagreement would only surface when an order failed.
     *
     * @return array<int, array<string, mixed>>
     */
    private function hostLoad(): array
    {
        return Host::query()
            // Preloaded so listing hosts is one query, not one per host.
            ->withSum(['instances as committed_disk_gb' => fn ($q) => $q->occupyingHost()], 'disk_gb')
            ->orderBy('name')
            ->get()
            ->map(function (Host $host) {
                $usable = $host->freeGb();
                $committed = $host->committedGb();
                // Same ratio usedPct() states, computed from the two values
                // already in hand rather than asking the host to sum again.
                $pct = $usable > 0 ? min(100, (int) round($committed / $usable * 100)) : 0;

                return [
                    'name' => $host->name,
                    'pct' => $pct,
                    'detail' => $usable > 0 ? "{$committed} / {$usable} GB" : __('admin.host_no_capacity'),
                    'level' => match (true) {
                        $usable === 0 => 'unknown',
                        $pct >= 90 => 'high',
                        $pct >= 75 => 'warn',
                        default => 'ok',
                    },
                ];
            })
            ->all();
    }

    /**
     * What each package can be promised today, and who is waiting.
     *
     * The same question the price sheet and the customer's own overview ask,
     * answered from the same place — an operator must be able to see on the
     * front page what a visitor is being told, without opening the shop in
     * another tab. A package with no host big enough is not unsellable: the
     * order is taken, parked, and rolled out once a machine arrives, which is
     * exactly what "bereitgestellt in 2-3 Werktagen" means.
     *
     * @return array{plans: array<int, array{name: string, needs: int, state: string}>, queued: int}
     */
    private function delivery(): array
    {
        $capacity = app(HostCapacity::class);

        try {
            $sellable = app(PlanCatalogue::class)->sellable();
        } catch (\Throwable) {
            // A catalogue that cannot be read has its own alarm. It does not
            // get to take the console's front page down with it.
            $sellable = [];
        }

        $plans = [];

        foreach ($sellable as $key => $plan) {
            $needs = (int) ($plan['disk_gb'] ?? 0);

            $plans[] = [
                'name' => (string) ($plan['name'] ?? $key),
                'needs' => $needs,
                'state' => $capacity->deliveryFor($needs),
            ];
        }

        return ['plans' => $plans, 'queued' => $capacity->queueDemand()['count']];
    }

    /**
     * Provisioning runs that have not finished.
     *
     * @return array<int, array<string, mixed>>
     */
    private function openRuns(): array
    {
        return ProvisioningRun::query()
            ->whereIn('status', [
                ProvisioningRun::STATUS_PENDING,
                ProvisioningRun::STATUS_RUNNING,
                ProvisioningRun::STATUS_WAITING,
                ProvisioningRun::STATUS_PAUSED,
            ])
            // Nested through the morph, so naming the customer does not cost a
            // query per run.
            ->with(['subject' => fn ($morph) => $morph->morphWith([Instance::class => ['customer']])])
            ->latest('id')
            ->limit(6)
            ->get()
            ->map(fn (ProvisioningRun $run) => [
                'subject' => $this->describeSubject($run),
                'step' => $this->currentStepLabel($run),
                'status' => $run->status,
            ])
            ->all();
    }

    /**
     * Everything currently wrong, from the records that know it.
     *
     * There is no invented alert here. If this list is empty, nothing in the
     * database is reporting a problem — which is a statement worth being able
     * to trust.
     *
     * @return array<int, array<string, string>>
     */
    private function notices(): array
    {
        $notices = [];

        $failedRuns = ProvisioningRun::query()->where('status', ProvisioningRun::STATUS_FAILED)->count();
        if ($failedRuns > 0) {
            $notices[] = [
                'level' => 'warning',
                'text' => __('admin.notice.failed_runs', ['n' => $failedRuns]),
                // Every notice carries the page that shows the thing it is
                // about. A warning an operator cannot act on from where they
                // read it is a warning they go looking for by hand, and the
                // count in the header was not even a link to the list.
                'route' => 'admin.provisioning',
            ];
        }

        foreach (Host::query()->where('status', 'error')->pluck('name') as $name) {
            $notices[] = [
                'level' => 'warning',
                'text' => __('admin.notice.host_error', ['host' => $name]),
                'route' => 'admin.hosts',
            ];
        }

        $silent = Host::query()
            ->where('status', 'active')
            ->where(fn ($q) => $q
                ->whereNull('last_seen_at')
                ->orWhere('last_seen_at', '<', Carbon::now()->subMinutes(self::HOST_SILENT_AFTER_MINUTES)))
            ->pluck('name');
        foreach ($silent as $name) {
            $notices[] = [
                'level' => 'warning',
                'text' => __('admin.notice.host_silent', [
                    'host' => $name, 'minutes' => self::HOST_SILENT_AFTER_MINUTES,
                ]),
                'route' => 'admin.hosts',
            ];
        }

        // Distinct instances, not targets: one instance can be watched by
        // several checks, and counting checks would report three outages where
        // one machine is down. And only verdicts the sync job has refreshed —
        // an unchecked row is not a healthy one, nor a failing one.
        $down = MonitoringTarget::query()
            ->where('status', 'down')
            ->where('checked_at', '>=', Carbon::now()->subMinutes(20))
            ->distinct()
            ->count('instance_id');
        if ($down > 0) {
            $notices[] = [
                'level' => 'warning',
                'text' => __('admin.notice.monitoring_down', ['n' => $down]),
                'route' => 'admin.instances',
            ];
        }

        // An address locked out after repeated failed sign-ins — host SSH or
        // an instance's own login, see SecurityBlock. Task 6 gave the two
        // kinds a section each (host detail, a customer's instance) but no
        // single list of both together, so this links to whichever list an
        // operator would actually find the flagged subject on: the host list
        // if a host is currently blocked (the more urgent case — somebody is
        // hammering a hypervisor's SSH), the instance list otherwise.
        $activeBlocks = SecurityBlock::active()->count();
        if ($activeBlocks > 0) {
            $notices[] = [
                'level' => 'warning',
                'text' => __('admin.notice.security_blocks', ['n' => $activeBlocks]),
                'route' => SecurityBlock::active()->whereNotNull('host_id')->exists() ? 'admin.hosts' : 'admin.instances',
            ];
        }

        // Capacity, before an order finds out.
        //
        // The platform books thick: a package reserves its whole `disk_gb` on
        // one host at placement. So the catalogue can go on offering a package
        // no host has room for, and the first anyone hears of it is a PAID
        // order failing at the reservation step — after the money.
        //
        // Listed per package with the shortfall, because "buy a server" is not
        // the decision; "buy a server big enough for the package I am selling"
        // is.
        foreach (app(HostCapacity::class)->unplaceablePlans() as $plan) {
            $notices[] = ['level' => 'warning', 'route' => 'admin.capacity', 'text' => __('admin.notice.no_capacity', [
                'plan' => $plan['name'],
                'needs' => $plan['needs'],
                'largest' => $plan['largest'],
            ])];
        }

        // The site being switched off. It is a deliberate state, not a fault —
        // but it answers 503 to every visitor who is not on the VPN, and the
        // only place that says so is the switch itself, three clicks away on
        // another page. Asked as "ich komme nicht auf www.dev", which is exactly
        // what it looks like from outside.
        if (! Settings::bool('site.public', true)) {
            $notices[] = [
                'level' => 'warning',
                'text' => __('admin.notice.site_hidden'),
                'route' => 'admin.settings',
            ];
        }

        // MAIL_MAILER=log. Every purpose mailer short-circuits to the log
        // transport then — see MailboxTransport::NON_DELIVERING — however well
        // the mailboxes are configured, and nothing is delivered to anybody.
        //
        // Worth its own notice because the failure is SILENT in every direction:
        // the queue reports success, no job fails, the console's own mailbox test
        // reports success (MailboxTester builds its own transport on purpose), and
        // the mail sits in storage/logs. Somebody registering and waiting for a
        // verification mail has no way to find that out. Asked exactly that way.
        if (! MailDelivery::delivers()) {
            $notices[] = [
                'level' => 'warning',
                'text' => __('admin.notice.mail_not_delivering', ['mailer' => (string) (MailDelivery::transport() ?? 'null')]),
                'route' => 'admin.mail',
            ];
        }

        // No SITE_HOST. The marketing site is then registered without a
        // hostname, so it answers on the PORTAL's host as well — and every
        // route() it generates points at APP_URL, which is why a link on the
        // site lands on the portal's domain.
        //
        // Only where the installation is host-separated at all. On a fresh
        // checkout nothing is bound to a hostname by design (see
        // RestrictAdminHost: "upgrading must not lock anyone out of a system
        // that was working"), and a warning there would be furniture — the same
        // mistake the capacity notice made before it learned to ask whether
        // there is a host at all.
        if (AdminArea::isHostBound() && config('admin_access.site_hosts') === []) {
            $notices[] = [
                'level' => 'warning',
                'text' => __('admin.notice.no_site_host', [
                    'app' => (string) parse_url((string) config('app.url'), PHP_URL_HOST),
                ]),
                'route' => 'admin.integrations',
            ];
        }

        // What is not yet set up at all, as opposed to everything above (what
        // IS set up but currently unhealthy). Readiness::blocking() is the
        // same list App\Livewire\Admin\Readiness renders in full — this is
        // only the count and a link to it, not a second copy of the reasons.
        $blockingReadiness = count(Readiness::blocking());
        if ($blockingReadiness > 0) {
            $notices[] = [
                'level' => 'warning',
                'text' => trans_choice('admin.notice.readiness_blocking', $blockingReadiness, ['n' => $blockingReadiness]),
                'route' => 'admin.readiness',
            ];
        }

        return $notices;
    }

    private function describeSubject(ProvisioningRun $run): string
    {
        $subject = $run->subject;

        return match (true) {
            $subject instanceof Instance => $subject->customer?->name ?? $subject->subdomain ?? '—',
            $subject instanceof Host => $subject->name,
            default => class_basename($run->subject_type ?? '').' #'.$run->subject_id,
        };
    }
}
