<?php

namespace App\Services;

use App\Models\Setting;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Process;
use Throwable;

/**
 * The panel's domain + TLS state, with a clear split between the PENDING domain
 * (changeable from the dashboard) and the ACTIVE domain (what the running stack
 * actually serves, frozen until a restart).
 *
 * - PENDING (configuredDomain): a dashboard override stored as Setting `panel_domain`
 *   wins over the install-time APP_DOMAIN. A panel_domain ROW — even empty — is an
 *   explicit choice (empty = bare IP); only the ABSENCE of the row falls back to
 *   APP_DOMAIN.
 * - ACTIVE (domain): read from a snapshot file written ONCE at container start
 *   (entrypoint -> `clusev:snapshot-domain`). So saving a new domain does NOT change
 *   how the panel is served until the operator restarts the stack — nothing switches
 *   mid-session, which is what makes it safe. Falls back to the pending value when no
 *   snapshot exists (dev / first run / tests).
 *
 * Everything serving-related (app.url, Caddy's /_caddy/ask, the Reverb client endpoint,
 * the HTTPS redirect/secure-cookie) keys off the ACTIVE domain; bare-IP/HTTP access
 * always stays a recovery path.
 */
class DeploymentService
{
    /** FQDN matcher (labels 1-63 chars, no scheme/port/path, TLD >= 2 alpha). */
    public const DOMAIN_REGEX = '/^(?=.{1,253}$)([a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}$/i';

    /** Snapshot of the active domain, written at container start (relative to storage/). */
    private const ACTIVE_FILE = 'framework/active-domain';

    /**
     * Restart sentinel — a marker file the dashboard writes to REQUEST a stack restart
     * (relative to storage/). It lives in its own bind-mounted directory (storage/app/
     * restart-signal, mapped to ./run on the host in docker-compose.prod.yml) so a
     * host-side watcher (a scoped systemd .path unit — see docker/restart-sentinel/)
     * sees it appear and runs `docker compose restart`, then deletes it. The container
     * NEVER gets the Docker socket; it only writes a non-sensitive marker file.
     */
    private const RESTART_FILE = 'app/restart-signal/restart.request';

    /**
     * Update sentinel — same mechanism and bind-mounted directory as RESTART_FILE, but the
     * host watcher reacts by running update.sh (git pull + idempotent re-install) instead of a
     * plain restart. Separate filename so the two requests trigger distinct host units.
     */
    private const UPDATE_FILE = 'app/restart-signal/update.request';

    /**
     * Update start marker — written (best-effort) when an update is requested, on the same
     * bind-mounted volume. The update-progress page reads it so its elapsed timer and live phase
     * checklist are anchored to the SERVER-side start, not to page-load time — a browser refresh
     * mid-update no longer resets the timer to zero nor drops the checklist back to phase one.
     */
    private const UPDATE_STARTED_FILE = 'app/restart-signal/update-started.json';

    /**
     * Coarse update macro-stage the host updater (update.sh / install.sh) writes as it progresses
     * (fetch → build → restart → migrate → done). Same bind-mounted directory. Read-only token.
     */
    private const UPDATE_PHASE_FILE = 'app/restart-signal/update-phase.json';

    /**
     * The PENDING domain: dashboard override (Setting `panel_domain`) if a row exists
     * (empty row = explicit bare IP), otherwise the install-time APP_DOMAIN. This is
     * what will become active after the next restart. null = bare IP / plain HTTP.
     */
    public function configuredDomain(): ?string
    {
        try {
            $db = Setting::get('panel_domain'); // null = no row; '' = explicit bare IP; 'x' = domain
        } catch (Throwable) {
            // Settings table not migrated yet (install/migrate) -> env fallback.
            $db = null;
        }

        $source = $db !== null ? $db : config('clusev.domain');

        return ($source !== null && trim((string) $source) !== '')
            ? strtolower(trim((string) $source))
            : null;
    }

    /**
     * The ACTIVE domain the running stack serves — frozen at container start. Falls back
     * to the pending value when no snapshot exists (dev / first run / tests).
     */
    public function domain(): ?string
    {
        $path = storage_path(self::ACTIVE_FILE);

        if (is_file($path)) {
            $value = trim((string) @file_get_contents($path));

            return $value !== '' ? strtolower($value) : null;
        }

        return $this->configuredDomain();
    }

    /** True once an active-domain snapshot file exists. */
    public function hasSnapshot(): bool
    {
        return is_file(storage_path(self::ACTIVE_FILE));
    }

    /**
     * Snapshot the configured domain as the active one. Called (with retries) by the
     * entrypoint at container start, so a domain change applies on restart and never
     * mid-session.
     *
     * Reads the override DIRECTLY from the database (not the Redis-cached Setting::get), so
     * it never freezes a stale env fallback when only the cache is down. Returns FALSE when
     * the setting can't be read authoritatively yet — DB unreachable OR the settings table
     * not migrated (fresh install) — so the caller retries and only then freezes the env
     * fallback. This guarantees the active domain is always a FIXED snapshot, never a live
     * value that could change when the DB/migrations catch up. Never throws.
     */
    public function snapshotActiveDomain(): bool
    {
        try {
            // null = no override row; '' = explicit bare IP; 'x' = domain.
            $db = DB::table('settings')->where('key', 'panel_domain')->value('value');
        } catch (Throwable) {
            return false; // DB down OR settings table not migrated yet — not authoritative
        }

        $source = $db !== null ? $db : config('clusev.domain');

        return $this->writeSnapshot($this->normalizeDomain($source));
    }

    /**
     * Freeze the install-time domain (env) as the active one when the settings are
     * unreachable at boot, so serving stays FIXED (the restart boundary holds) instead of
     * falling back to a live value. A dashboard override (DB-only) then applies on the next
     * restart where the database/migrations are ready.
     */
    public function snapshotFallback(): bool
    {
        return $this->writeSnapshot($this->normalizeDomain(config('clusev.domain')));
    }

    /** Lowercased/trimmed domain, or null when empty (bare IP). */
    private function normalizeDomain(mixed $value): ?string
    {
        return ($value !== null && trim((string) $value) !== '')
            ? strtolower(trim((string) $value))
            : null;
    }

    private function writeSnapshot(?string $domain): bool
    {
        try {
            $dir = storage_path('framework');
            if (! is_dir($dir)) {
                @mkdir($dir, 0775, true);
            }

            return @file_put_contents(storage_path(self::ACTIVE_FILE), (string) $domain) !== false;
        } catch (Throwable) {
            return false;
        }
    }

    /** A restart is needed when the pending domain differs from the active one. */
    public function restartPending(): bool
    {
        return $this->configuredDomain() !== $this->domain();
    }

    /** Absolute path of the restart sentinel on the shared (bind-mounted) volume. */
    public function restartSignalPath(): string
    {
        return storage_path(self::RESTART_FILE);
    }

    /**
     * Request a stack restart by writing the sentinel file. A host-side watcher (a scoped
     * systemd .path unit, see docker/restart-sentinel/) reacts to its appearance and runs
     * `docker compose restart`, then removes it. The content is a non-sensitive marker
     * (an ISO timestamp) — the trigger is the file existing, not what it holds. Never throws.
     */
    public function requestRestart(): bool
    {
        $path = $this->restartSignalPath();
        $dir = dirname($path);

        if (! is_dir($dir)) {
            @mkdir($dir, 0775, true);
        }

        // Returns false when the (bind-mounted) sentinel dir is not writable by the app user, so
        // the caller can surface that instead of a "restarting" state that never resolves.
        return @file_put_contents($path, now()->toIso8601String().PHP_EOL) !== false;
    }

    /** True while a restart request is pending (the host watcher clears it once handled). */
    public function restartRequested(): bool
    {
        return is_file($this->restartSignalPath());
    }

    /** Remove the sentinel (used by tests / a manual reset; the host watcher deletes it normally). */
    public function clearRestartRequest(): void
    {
        $path = $this->restartSignalPath();

        if (is_file($path)) {
            @unlink($path);
        }
    }

    /** Absolute path of the update sentinel on the shared (bind-mounted) volume. */
    public function updateSignalPath(): string
    {
        return storage_path(self::UPDATE_FILE);
    }

    /** Absolute path of the update-start marker on the shared (bind-mounted) volume. */
    public function updateStartedPath(): string
    {
        return storage_path(self::UPDATE_STARTED_FILE);
    }

    /**
     * Epoch seconds when the current update was requested, or null if there is no readable marker.
     * The update-progress page anchors its elapsed timer and live phase feed to this so both
     * survive a browser refresh instead of resetting on every page load. Never throws.
     */
    public function updateStartedAt(): ?int
    {
        $path = $this->updateStartedPath();
        if (! is_file($path)) {
            return null;
        }
        $data = json_decode((string) @file_get_contents($path), true);
        $at = is_array($data) ? ($data['at'] ?? null) : null;

        return is_int($at) && $at > 0 ? $at : null;
    }

    /**
     * The current update macro-stage written by the host updater, or null when absent/unrecognised.
     * Only ever returns a whitelisted stage token (never arbitrary file content). Shape:
     * ['stage' => 'fetch'|'build'|'restart'|'migrate'|'done', 'at' => <epoch>]. Never throws.
     *
     * @return array{stage: string, at: int}|null
     */
    public function updatePhase(): ?array
    {
        $path = storage_path(self::UPDATE_PHASE_FILE);
        if (! is_file($path)) {
            return null;
        }
        $data = json_decode((string) @file_get_contents($path), true);
        if (! is_array($data) || ! in_array($data['stage'] ?? null, ['fetch', 'build', 'restart', 'migrate', 'done', 'error'], true)) {
            return null;
        }
        // Require a real timestamp: the progress page's freshness logic ties a stage to THIS update
        // via its `at`. A missing/zero/non-int `at` (corrupt file, or the host's date-failed `echo 0`
        // fallback) must NOT pass as a current stage — otherwise a stale 'done' could trigger a
        // premature completion redirect. Treat it as "no stage" and let the page fall back.
        $at = $data['at'] ?? null;
        if (! is_int($at) || $at <= 0) {
            return null;
        }

        return ['stage' => (string) $data['stage'], 'at' => $at];
    }

    /**
     * Request a stack UPDATE by writing the update sentinel. A host-side watcher (the scoped
     * clusev-update.path unit) reacts by running update.sh — git pull + idempotent re-install
     * (rebuild, migrate, restart) — then removes the sentinel. The container never runs git or
     * Docker itself; it only writes this non-sensitive marker. Never throws.
     */
    public function requestUpdate(): bool
    {
        $path = $this->updateSignalPath();
        $dir = dirname($path);

        if (! is_dir($dir)) {
            @mkdir($dir, 0775, true);
        }

        // Sign the marker so the host updater (watch.sh) can reject a forged/limited write to the
        // shared ./run dir before running a root update: line 1 = timestamp, line 2 = its HMAC.
        // Falls back to the bare timestamp when no key is configured (dev / pre-HMAC installs), which
        // the host accepts too.
        $ts = now()->toIso8601String();
        $key = (string) config('clusev.update_hmac_key');
        $body = $key !== ''
            ? $ts.PHP_EOL.hash_hmac('sha256', $ts, $key).PHP_EOL
            : $ts.PHP_EOL;

        // Returns false when the (bind-mounted) sentinel dir is not writable by the app user —
        // the caller surfaces that instead of showing a "running" state that never resolves.
        $written = @file_put_contents($path, $body) !== false;

        if ($written) {
            // Best-effort start marker (a failure here must never fail the update): lets the
            // progress page render a refresh-proof elapsed time and accept the live phase feed
            // regardless of how long the current stage has been running.
            @file_put_contents($this->updateStartedPath(), json_encode([
                'at' => now()->timestamp,
                'from' => (string) config('clusev.version'),
            ]).PHP_EOL);
        }

        return $written;
    }

    /** True while an update request is pending (the host watcher clears it once handled). */
    public function updateRequested(): bool
    {
        return is_file($this->updateSignalPath());
    }

    /** Remove the update sentinel + its start marker (tests / manual reset; the host watcher deletes
     *  the sentinel normally). */
    public function clearUpdateRequest(): void
    {
        foreach ([$this->updateSignalPath(), $this->updateStartedPath()] as $path) {
            if (is_file($path)) {
                @unlink($path);
            }
        }
    }

    /** True when the panel domain is a dashboard override (a panel_domain row exists). */
    public function domainIsOverridden(): bool
    {
        try {
            return Setting::get('panel_domain') !== null;
        } catch (Throwable) {
            return false;
        }
    }

    /**
     * Persist a new panel domain. Pass null/'' to choose bare IP (an explicit override
     * that also disables the install-time APP_DOMAIN). Returns the pending domain.
     */
    public function setDomain(?string $domain): ?string
    {
        Setting::put('panel_domain', $domain !== null ? strtolower(trim($domain)) : '');

        return $this->configuredDomain();
    }

    /** True when an upstream reverse proxy terminates TLS (the panel serves HTTP; Caddy issues no certs). */
    public function externalTls(): bool
    {
        try {
            return Setting::get('tls_mode', 'caddy') === 'external';
        } catch (Throwable) {
            return false;
        }
    }

    /** Persist the TLS mode (only the two valid values; anything else falls back to caddy). */
    public function setTlsMode(string $mode): void
    {
        Setting::put('tls_mode', $mode === 'external' ? 'external' : 'caddy');
    }

    /** True once an ACTIVE domain is configured — i.e. automatic TLS is in effect. */
    public function hasTls(): bool
    {
        return $this->domain() !== null;
    }

    /** The HTTPS URL the panel is reachable at for the ACTIVE domain. */
    public function panelUrl(): ?string
    {
        $domain = $this->domain();

        return $domain !== null ? 'https://'.$domain : null;
    }

    /**
     * Browser-facing Reverb (Echo) endpoint. Derived from the CURRENT request, because the
     * browser always reaches Reverb through the SAME front door it loaded the panel from:
     * both Caddy (prod) and nginx (dev) tunnel /app/* + /apps/* to the internal reverb:8080.
     * So the endpoint is always the live request's host+port+scheme — never a stale .env
     * REVERB_* value (which would break realtime after a domain change/clear) and needing no
     * JS rebuild. Falls back to config/reverb.php only when there is no request context.
     *
     * @return array{key: ?string, host: ?string, port: int, scheme: string}
     */
    public function reverbClient(): array
    {
        $key = config('broadcasting.connections.reverb.key');
        $request = request();
        $domain = $this->domain();

        if ($request !== null) {
            // Served over the active domain → the browser reached the panel through the HTTPS
            // front door (Caddy or an external TLS proxy on 443), which tunnels /app/* + /apps/*
            // to reverb. Connect Reverb the SAME way — wss on 443 — derived from the active
            // domain, NOT the request scheme/port: behind an external TLS proxy those look like
            // plain HTTP:80 here, which would (wrongly) produce wss://host:80 and fail.
            if ($domain !== null && strtolower($request->getHost()) === $domain) {
                return ['key' => $key, 'host' => $domain, 'port' => 443, 'scheme' => 'https'];
            }

            // Bare IP / recovery path → exactly what the request used (plain ws over the IP).
            return [
                'key' => $key,
                'host' => $request->getHost(),
                'port' => $request->getPort(),
                'scheme' => $request->isSecure() ? 'https' : 'http',
            ];
        }

        $opt = config('reverb.apps.apps.0.options', []);

        return [
            'key' => $key,
            'host' => $opt['host'] ?? '127.0.0.1',
            'port' => (int) ($opt['port'] ?? 80),
            'scheme' => $opt['scheme'] ?? 'http',
        ];
    }

    /**
     * The server's own public IP, used only to pre-check that a domain's DNS points here
     * before triggering ACME. Best-effort (cached ~5 min on success); null when it can't be
     * determined — the caller then treats the DNS comparison as "unknown" and proceeds, since
     * the actual issuance handshake is the real source of truth.
     */
    public function serverPublicIp(): ?string
    {
        $cached = Cache::get('clusev:public-ip');
        if (is_string($cached) && $cached !== '') {
            return $cached;
        }

        try {
            $res = Process::timeout(5)->run(['curl', '-fsS', '--max-time', '3', 'https://api.ipify.org']);
            $ip = trim($res->output());
        } catch (Throwable) {
            $ip = '';
        }

        if (filter_var($ip, FILTER_VALIDATE_IP)) {
            Cache::put('clusev:public-ip', $ip, now()->addMinutes(5));

            return $ip;
        }

        return null;
    }

    /**
     * Whether the domain's DNS (A/AAAA) currently resolves to THIS server.
     *
     * @return array{ok: ?bool, resolved: string[], serverIp: ?string}
     *                                                                 ok: true = matches, false = clear mismatch, null = unknown (public IP undeterminable)
     */
    public function domainResolvesHere(string $domain): array
    {
        $domain = strtolower(trim($domain));
        $serverIp = $this->serverPublicIp();

        $resolved = [];
        try {
            foreach ([DNS_A, DNS_AAAA] as $type) {
                foreach (@dns_get_record($domain, $type) ?: [] as $rec) {
                    $ip = $rec['ip'] ?? $rec['ipv6'] ?? null;
                    if (is_string($ip) && $ip !== '') {
                        $resolved[] = $ip;
                    }
                }
            }
        } catch (Throwable) {
            // resolution failed — treat as no records
        }
        $resolved = array_values(array_unique($resolved));

        $ok = $serverIp === null ? null : in_array($serverIp, $resolved, true);

        return ['ok' => $ok, 'resolved' => $resolved, 'serverIp' => $serverIp];
    }

    /**
     * Trigger Caddy's on-demand TLS for the domain by doing an internal HTTPS handshake to the
     * `caddy` service with SNI = the domain (`--connect-to` pins the dial to caddy regardless of
     * the domain, so there is no SSRF — the connection target is fixed, the domain only sets
     * SNI/Host). Returns true only when curl completes WITH certificate verification (exit 0),
     * i.e. Caddy is now serving a trusted (Let's Encrypt) cert for the domain. Never throws.
     */
    public function probeCertificate(string $domain): bool
    {
        $domain = strtolower(trim($domain));
        if ($domain === '') {
            return false;
        }

        try {
            $res = Process::timeout(25)->run([
                'curl', '-sS', '-o', '/dev/null', '-w', '%{http_code}',
                '--connect-to', "{$domain}:443:caddy:443",
                '--max-time', '20',
                "https://{$domain}/",
            ]);

            // Exit 0 means the TLS handshake AND certificate verification succeeded (curl exits
            // 60 on an untrusted/self-signed cert) — so a trusted cert is being served.
            return $res->successful();
        } catch (Throwable) {
            return false;
        }
    }

    /**
     * Operator-triggered certificate request: DNS pre-check, then (only if DNS is not a clear
     * mismatch) trigger on-demand issuance and verify. Persists the outcome to a Setting so the
     * dashboard can show a status without a live (ACME-triggering) probe on every page load.
     *
     * @return array{status: string, checkedAt: string, resolved?: string[], serverIp?: ?string}
     *                                                                                           status: issued | dns_mismatch | failed | not_applicable
     */
    public function requestCertificate(string $domain): array
    {
        $domain = strtolower(trim($domain));

        if ($domain === '' || $this->externalTls()) {
            return $this->persistCertStatus('not_applicable');
        }

        $dns = $this->domainResolvesHere($domain);
        if ($dns['ok'] === false) {
            // Don't trigger ACME against a wrong host — protects the Let's Encrypt failed-
            // validation rate limit.
            return $this->persistCertStatus('dns_mismatch', [
                'resolved' => $dns['resolved'],
                'serverIp' => $dns['serverIp'],
            ]);
        }

        $issued = $this->probeCertificate($domain);

        return $this->persistCertStatus($issued ? 'issued' : 'failed');
    }

    /** Last persisted certificate-request outcome, or null when never checked. */
    public function certStatus(): ?array
    {
        try {
            $raw = Setting::get('tls_cert_status');
        } catch (Throwable) {
            return null;
        }

        if (! is_string($raw) || $raw === '') {
            return null;
        }

        $decoded = json_decode($raw, true);

        return is_array($decoded) ? $decoded : null;
    }

    /** @return array{status: string, checkedAt: string} */
    private function persistCertStatus(string $status, array $extra = []): array
    {
        $payload = array_merge(['status' => $status, 'checkedAt' => now()->toIso8601String()], $extra);

        try {
            Setting::put('tls_cert_status', json_encode($payload));
        } catch (Throwable $e) {
            // Status display is best-effort (the notify still reports the live result), but
            // surface the failure to logs so a broken settings store doesn't fail silently.
            report($e);
        }

        return $payload;
    }
}
