<?php

namespace App\Livewire;

use App\Livewire\Concerns\ResolvesCustomer;
use App\Models\Customer;
use App\Models\Datacenter;
use App\Models\Instance;
use App\Models\InstanceMetric;
use App\Models\MaintenanceWindow;
use App\Models\Seat;
use App\Models\Subscription;
use App\Services\Traffic\TrafficMeter;
use Illuminate\Support\Carbon;
use Livewire\Attributes\Layout;
use Livewire\Component;

/**
 * The customer's Betriebsblatt: what they have, and the evidence that it is
 * being looked after.
 *
 * Every figure on this page comes from a record. Where something is not
 * measured yet — storage consumption, restore tests — the page says nothing
 * rather than showing a plausible number: this is the sheet a customer forwards
 * to their auditor, and one invented line on it is worse than a short register.
 */
#[Layout('layouts.portal-app')]
class Dashboard extends Component
{
    use ResolvesCustomer;

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

        // The instance that is actually in service. A cancelled one still has
        // rows, and would otherwise keep filling this page after it is gone.
        $instance = $customer?->instances()
            ->whereIn('status', ['active', 'provisioning', 'cancellation_scheduled'])
            ->latest('id')
            ->first();

        $contract = $instance?->subscription;
        $maintenance = MaintenanceWindow::forInstance($instance)->first();
        $domain = $this->domain($instance);

        return view('livewire.dashboard', [
            'customer' => $customer,
            'instance' => $instance,
            'contract' => $contract,
            'domain' => $domain,
            'location' => $this->location($instance),
            'seats' => $this->seats($customer, $contract),
            'traffic' => $instance !== null ? TrafficMeter::for($instance) : null,
            // The measured series behind the template's ring and trend. Null
            // where nothing has been sampled yet — a new instance has no
            // fortnight, and drawing one at zero would tell its owner their
            // data had vanished.
            'disk' => $instance !== null ? $this->disk($instance) : null,
            'availability' => $instance !== null ? InstanceMetric::availability($instance) : null,
            'availabilityTrend' => $instance !== null ? InstanceMetric::availabilitySeries($instance) : [],
            'seatBreakdown' => $this->seatBreakdown($customer),
            'trend' => $instance !== null
                ? InstanceMetric::series($instance)->map(fn (InstanceMetric $m) => $m->rx_bytes + $m->tx_bytes)->all()
                : [],
            'proofs' => $this->proofs($instance, $maintenance),
            'openTasks' => $instance?->onboardingTasks()->where('done', false)->count() ?? 0,
            // What the plan costs is true whether or not another invoice is
            // coming — a customer who has given notice still pays until the
            // term ends and still needs the figure on their master record.
            'planPrice' => $contract === null ? null : [
                'cents' => (int) $contract->price_cents,
                'currency' => (string) ($contract->currency ?: 'EUR'),
                'term' => (string) $contract->term,
            ],
            'nextInvoice' => $this->nextInvoice($contract, $instance),
            // The initial admin password, if it is still waiting to be
            // acknowledged. Read straight off $instance (already scoped to
            // THIS customer above) rather than a fresh unscoped lookup, and
            // never stored as a public property — see acknowledgeCredentials().
            'credentials' => $this->credentials($instance, $domain),
            // Die Frist, die den Kunden betrifft — wie lange SEIN Zugang noch
            // steht, nicht wann irgendetwas geloescht wird. Siehe ending().
            'ending' => $this->ending($instance),
            // Wann die Laufzeit endete — nur, wenn gar keine Instanz mehr in
            // Betrieb ist. Siehe endedAt().
            'endedAt' => $this->endedAt($customer, $instance),
            'asOf' => Carbon::now(),
        ]);
    }

    /**
     * Wann die Laufzeit dieses Kunden endete — oder null.
     *
     * Die Abfrage oben holt nur, was in Betrieb ist; eine `ended`-Instanz
     * faellt heraus, und der Kunde landete dadurch in demselben Zweig wie
     * jemand, der noch nie etwas bestellt hat. Er las „Ihre Cloud wird
     * eingerichtet." und bekam einen Knopf „Paket buchen" — am Tag, an dem
     * ihm EndInstanceService die Adresse eingezogen hat.
     *
     * Nur gefragt, wenn nichts mehr laeuft: wer neben der abgelaufenen
     * Instanz schon wieder eine aktive hat, ist kein beendeter Kunde, und
     * seine Seite soll von der alten nichts erzaehlen.
     *
     * `service_ends_at` ist dabei nie null — `ended` wird ausschliesslich
     * ueber EndInstanceService::hasEnded() erreicht, und die verlangt das
     * Datum. Trotzdem hier verlangt, statt darauf zu vertrauen: ein Satz mit
     * „endete am —" waere schlimmer als der Zweig darunter.
     */
    private function endedAt(?Customer $customer, ?Instance $instance): ?Carbon
    {
        if ($customer === null || $instance !== null) {
            return null;
        }

        return $customer->instances()
            ->where('status', 'ended')
            ->whereNotNull('service_ends_at')
            ->latest('id')
            ->first()?->service_ends_at;
    }

    /**
     * Aendert die Antwort zum Export, solange sie noch etwas bedeutet: bis
     * zum Laufzeitende. Wie acknowledgeCredentials() loest diese Methode
     * Kunde UND Instanz selbst neu auf, statt einer vom Browser hydrierten
     * Eigenschaft zu vertrauen — sie ist ueber /livewire/update erreichbar,
     * nicht nur ueber den Schalter im Streifen, und traegt deshalb dieselbe
     * Bedingung wie ending() unten: verschwindet der Streifen, weil die
     * Kuendigung nicht mehr aktiv ist oder das Laufzeitende vorbei ist, darf
     * dieser Weg auch am Formular vorbei nichts mehr aendern.
     */
    public function setExportWish(bool $wish): void
    {
        $customer = $this->requireCustomer();
        if ($customer === null) {
            return;
        }

        $instance = $customer->instances()
            ->where('status', 'cancellation_scheduled')
            ->where('service_ends_at', '>', now())
            ->latest('id')
            ->first();

        if ($instance === null) {
            return;
        }

        $instance->update(['export_wish' => $wish]);
    }

    /**
     * Was der Streifen ueber die Restfrist braucht — oder null, wenn er gar
     * nicht erscheinen soll.
     *
     * Zeigt sich nur, wenn eine Kuendigung vorgemerkt ist UND das
     * Laufzeitende noch in der Zukunft liegt. Der zweite Teil ist kein
     * Sonderfall am Rand: der stuendliche Lauf, der eine abgelaufene Instanz
     * auf `ended` stellt, laesst einen DNS-Fehler bewusst durch
     * (EndInstanceService) und kann deshalb real tagelang hinterherhinken.
     * Ohne diese Pruefung wuerde der Streifen in genau diesem Fenster eine
     * abgelaufene Frist als Restzeit ausgeben — der Fehler, den niemand
     * meldet, weil er wie eine Kleinigkeit aussieht, und den jeder sieht.
     *
     * @return array{ends_at: Carbon, days: int, hours: int, show_days: bool}|null
     */
    private function ending(?Instance $instance): ?array
    {
        if ($instance === null || $instance->status !== 'cancellation_scheduled' || $instance->service_ends_at === null) {
            return null;
        }

        if (! $instance->service_ends_at->isFuture()) {
            return null;
        }

        // Nie negativ, auch nicht auf dem Papier: geklemmt, obwohl die
        // Pruefung oben das im Normalfall schon ausschliesst — die Zeit
        // zwischen der Berechnung hier und der Anzeige im Browser soll sich
        // nie als "-1 Sekunde" durchschlagen koennen.
        $seconds = max(0, $instance->service_ends_at->getTimestamp() - Carbon::now()->getTimestamp());

        return [
            'ends_at' => $instance->service_ends_at,
            // Abgerundet auf ganze Tage, solange mehr als ein Tag bleibt —
            // "noch 12 Tage" liest sich, "noch 11 Tage 7 Stunden" nicht. Am
            // letzten Tag zaehlt sie Stunden; das ist der Moment, in dem die
            // Zahl wirklich zaehlt.
            'days' => intdiv($seconds, 86400),
            'hours' => intdiv($seconds, 3600),
            'show_days' => $seconds > 86400,
        ];
    }

    /**
     * Deletes the instance's stored admin password and stamps the
     * acknowledgement — the customer has confirmed they noted it down.
     *
     * Re-resolves both the customer AND the instance from the authenticated
     * session rather than trusting $uuid alone: this method is reachable by
     * anyone who can post to /livewire/update, not only through this card's
     * button, so ownership is checked here again rather than relying on the
     * card simply not being shown to anyone else.
     */
    public function acknowledgeCredentials(string $uuid): void
    {
        $customer = $this->requireCustomer();
        if ($customer === null) {
            return;
        }

        $instance = $customer->instances()->where('uuid', $uuid)->whereNotNull('admin_password')->first();
        if ($instance === null) {
            return;
        }

        $instance->update([
            'admin_password' => null,
            'credentials_acknowledged_at' => now(),
        ]);

        $this->dispatch('notify', message: __('dashboard.credentials.acknowledged'));
    }

    /**
     * @return array{uuid: string, url: string, user: string, password: string}|null
     */
    private function credentials(?Instance $instance, ?string $domain): ?array
    {
        if ($instance === null || $instance->admin_password === null) {
            return null;
        }

        return [
            'uuid' => $instance->uuid,
            'url' => 'https://'.($domain ?: $instance->subdomain),
            'user' => (string) $instance->nc_admin_ref,
            'password' => $instance->admin_password,
        ];
    }

    /**
     * Who holds the seats, so the card can say something true underneath the
     * figure rather than repeating it.
     *
     * @return array<string, int> role => count, roles with nobody omitted
     */
    private function seatBreakdown(?Customer $customer): array
    {
        if ($customer === null) {
            return [];
        }

        return Seat::query()
            ->where('customer_id', $customer->id)
            ->whereIn('status', ['active', 'invited'])
            ->selectRaw('role, COUNT(*) as n')
            ->groupBy('role')
            ->pluck('n', 'role')
            ->filter()
            ->all();
    }

    /**
     * How full the instance is, as measured — not as sold.
     *
     * Null until the sampler has managed a reading. The card then states the
     * contractual allowance, which is true, rather than a ring at a level
     * nobody measured.
     *
     * @return array{used: int, total: int, percent: float, week_delta: int|null}|null
     */
    private function disk(Instance $instance): ?array
    {
        $metric = InstanceMetric::latestDisk($instance);

        if ($metric === null || ! $metric->disk_total_bytes) {
            return null;
        }

        // What it grew by this week, which is the line the template carries
        // under the ring. Null when there is no reading a week back to compare
        // against — an instance three days old has no weekly trend, and
        // inventing "+0 GB" would read as "nothing happened".
        $weekAgo = InstanceMetric::query()
            ->where('instance_id', $instance->id)
            ->whereNotNull('disk_used_bytes')
            ->where('day', '<=', now()->subDays(7)->toDateString())
            ->orderByDesc('day')
            ->first();

        return [
            'used' => $metric->disk_used_bytes,
            'total' => $metric->disk_total_bytes,
            'percent' => round($metric->disk_used_bytes / max(1, $metric->disk_total_bytes) * 100, 1),
            'week_delta' => $weekAgo !== null ? $metric->disk_used_bytes - $weekAgo->disk_used_bytes : null,
        ];
    }

    /** The address the customer actually reaches their cloud at. */
    private function domain(?Instance $instance): ?string
    {
        if ($instance === null) {
            return null;
        }

        // See Instance::address(): the customer's own domain counts only once
        // its DNS proof has been read.
        return $instance->subdomain
            ? $instance->address(\App\Support\ProvisioningSettings::dnsZone())
            : null;
    }

    /**
     * Where the data physically sits.
     *
     * Named from the datacenter register rather than from a translation string,
     * because this is the line a customer copies into a processing record — it
     * has to be the place their instance really runs on, not the place we
     * usually sell.
     *
     * @return array{name: string, note: null}|null
     */
    private function location(?Instance $instance): ?array
    {
        $code = $instance?->host?->datacenter;

        if ($code === null || $code === '') {
            return null;
        }

        $datacenter = Datacenter::query()->where('code', $code)->first();

        // The country, not the site. "Falkenstein" is how an operator places an
        // instance; a customer's processing record says which jurisdiction the
        // data sits in, and naming the building means editing customer-facing
        // copy every time the estate grows.
        return [
            'name' => $datacenter?->location ?: $code,
            'note' => null,
        ];
    }

    /**
     * Seats in use against seats bought.
     *
     * Invited seats count as used: the licence is committed the moment the
     * invitation goes out, and showing them as free is how a customer finds out
     * at the worst moment that they cannot add the person in front of them.
     *
     * @return array{used: int, total: int|null}
     */
    private function seats(?Customer $customer, ?Subscription $contract): array
    {
        return [
            'used' => $customer === null ? 0 : Seat::query()
                ->where('customer_id', $customer->id)
                ->whereIn('status', ['active', 'invited'])
                ->count(),
            'total' => $contract?->seats,
        ];
    }

    /**
     * The proof register — what was done for this customer, and when.
     *
     * Only entries backed by a record. A row here is something the customer can
     * be asked to evidence, so "we back up nightly" is not one; "the last backup
     * completed at 03:12 and reported ok" is.
     *
     * @return array<int, array{key: string, at: \Illuminate\Support\Carbon|null, state: string, note: string|null}>
     */
    private function proofs(?Instance $instance, ?MaintenanceWindow $maintenance): array
    {
        if ($instance === null) {
            return [];
        }

        $proofs = [];

        // A failing job outranks a succeeding one, whatever their dates.
        //
        // Ordering by last_ok_at alone hides exactly the case this register
        // exists for: last night's job failed and so has no success time at
        // all, an older row that DID succeed sorts above it, and the sheet
        // reports backups as fine. On the page a customer shows their auditor,
        // a problem has to beat a date.
        $backups = $instance->backups()->get();
        $backup = $backups->first(fn ($b) => $b->status !== 'ok')
            ?? $backups->sortByDesc('last_ok_at')->first();

        if ($backup !== null) {
            $proofs[] = [
                'key' => 'backup',
                'at' => $backup->last_ok_at,
                // The job's own verdict, not "there is a row, so it worked".
                'state' => $backup->status === 'ok' ? 'ok' : 'attention',
                'note' => $backup->schedule,
            ];
        }

        // A certificate that stopped renewing is the failure a customer meets as
        // "my browser says my own cloud is unsafe", so it is stated either way.
        $proofs[] = [
            'key' => 'certificate',
            'at' => null,
            'state' => $instance->cert_ok ? 'ok' : 'attention',
            'note' => null,
        ];

        if ($maintenance !== null) {
            $proofs[] = [
                'key' => 'maintenance',
                'at' => $maintenance->starts_at,
                'state' => 'planned',
                'note' => $maintenance->title,
            ];
        }

        if ($instance->service_ends_at !== null) {
            $proofs[] = [
                'key' => 'service_ends',
                'at' => $instance->service_ends_at,
                'state' => 'attention',
                'note' => null,
            ];
        }

        // Newest first, but an entry with no time of its own — the certificate —
        // stays at the top rather than sinking to the bottom of the register.
        usort($proofs, fn (array $a, array $b) => ($b['at']?->timestamp ?? PHP_INT_MAX) <=> ($a['at']?->timestamp ?? PHP_INT_MAX));

        return $proofs;
    }

    /**
     * What is owed next, from the contract rather than from a price list.
     *
     * The plan and the modules are kept apart. A customer with modules booked
     * would otherwise read the plan's price as their bill and be short every
     * month — and putting the combined figure next to the word "Paket" on the
     * master record would be just as wrong in the other direction.
     *
     * Add-ons carry a MONTHLY price (SubscriptionAddon::monthlyCents), while the
     * plan's price_cents is the price of a whole term. On a yearly contract the
     * modules therefore multiply by twelve to land on the same invoice.
     *
     * Nothing at all once the customer has given notice. Cancelling leaves the
     * subscription `active` until the term runs out — that is deliberate, the
     * service keeps running — but `current_period_end` is then the day service
     * ENDS, not the day the next charge falls. Billing a customer, on the sheet
     * they check their invoices against, for a renewal that will never happen is
     * the worst single line this page could carry. The end date is still stated;
     * it is in the proof register as "contract ends".
     *
     * @return array{plan: int, addons: int, total: int, currency: string, due: \Illuminate\Support\Carbon|null, term: string}|null
     */
    private function nextInvoice(?Subscription $contract, ?Instance $instance): ?array
    {
        if ($contract === null || $contract->status === 'cancelled') {
            return null;
        }

        if ($instance?->cancel_requested_at !== null || $instance?->service_ends_at !== null) {
            return null;
        }

        $months = $contract->isYearly() ? 12 : 1;

        $addons = $contract->addons
            ->whereNull('cancelled_at')
            ->sum(fn ($addon) => $addon->monthlyCents()) * $months;

        return [
            'plan' => (int) $contract->price_cents,
            'addons' => (int) $addons,
            'total' => (int) $contract->price_cents + (int) $addons,
            'currency' => (string) ($contract->currency ?: 'EUR'),
            'due' => $contract->current_period_end,
            'term' => (string) $contract->term,
        ];
    }
}
