<?php

declare(strict_types=1);

namespace PpPhp\Core\Licensing;

use PpPhp\Core\PlatformSettings;

/**
 * Where the licence authority lives — and where clients should look if the
 * usual address does not answer.
 *
 * Owner-managed from the PRIMARY install. The resulting list travels to every
 * customer inside the signed lease, so:
 *   • it cannot be tampered with in transit,
 *   • adding or moving a mirror reaches the whole estate within 24 hours,
 *     with nobody re-entering anything.
 *
 * A mirror is a second PPPHP install running the same licence module with the
 * SAME signing key and a replica of the pp_license* tables. It exists so a dead
 * primary is a non-event; it is not a way to run a different key.
 */
final class LicenseConfig
{
    /** The permanent home of the PPPHP licence service. */
    public const DEFAULT_PRIMARY = 'https://ppphp.ppradhan.com';

    public const SETTING_PRIMARY = 'platform.license_server_url';
    public const SETTING_MIRRORS = 'platform.license_mirrors';
    /** Vendor-set display labels for servers: { normalised-url => label }. */
    public const SETTING_LABELS  = 'platform.license_server_labels';
    public const SETTING_HTTPS   = 'platform.license_require_https';
    public const SETTING_SUPPORT = 'platform.support_contact';

    public const SETTING_RELEASE_KEY = 'platform.protected_release_key';

    /**
     * The content key for the current PROTECTED release, base64.
     *
     * Set by the packager whenever it seals a protected bundle, and handed only
     * to good seats inside the signed lease. Rotating it per release bounds what
     * a customer who captured one key can still open.
     */
    public static function releaseKeyB64(): string
    {
        return trim((string) (PlatformSettings::get(self::SETTING_RELEASE_KEY)['value'] ?? ''));
    }

    public static function setReleaseKey(string $rawKey): bool
    {
        return PlatformSettings::set(self::SETTING_RELEASE_KEY, ['value' => base64_encode($rawKey)]);
    }

    /** Support contact shown to a client if it locks (default = the vendor's). */
    public static function supportContact(): string
    {
        $v = trim((string) (PlatformSettings::get(self::SETTING_SUPPORT)['value'] ?? ''));
        return $v !== '' ? $v : LicenseProtocol::SUPPORT_CONTACT;
    }

    public static function setSupportContact(string $email): bool
    {
        return PlatformSettings::set(self::SETTING_SUPPORT, ['value' => trim($email)]);
    }

    // ── Mesh resilience & rate-limit knobs ──────────────────────────────
    // Server-set. The MESH values (grace/heartbeat/timeout/self-heal) ride the
    // signed lease to every install; the RATE-LIMIT values are enforced here.

    private const SETTING_RUNTIME_GRACE = 'platform.runtime_grace_hours';
    private const SETTING_HEARTBEAT     = 'platform.heartbeat_minutes';
    private const SETTING_NET_TIMEOUT   = 'platform.network_timeout';
    private const SETTING_SELF_HEAL     = 'platform.mesh_self_heal';
    private const SETTING_RATE_LIMIT    = 'platform.rate_limit';
    private const SETTING_RATE_WINDOW   = 'platform.rate_window';

    private static function num(string $key, int $default, int $min, int $max): int
    {
        try {
            $v = (int) (PlatformSettings::get($key)['value'] ?? 0);
            return $v > 0 ? max($min, min($max, $v)) : $default;
        } catch (\Throwable $e) { return $default; }
    }
    private static function setNum(string $key, int $v, int $min, int $max): bool
    {
        try { return PlatformSettings::set($key, ['value' => max($min, min($max, $v))]); }
        catch (\Throwable $e) { return false; }
    }

    /** How long an ACTIVATED install runs during a total mesh outage (hours). */
    public static function runtimeGraceHours(): int { return self::num(self::SETTING_RUNTIME_GRACE, (int) round(LicenseProtocol::RUNTIME_GRACE / 3600), 1, 8760); }
    public static function setRuntimeGraceHours(int $h): bool { return self::setNum(self::SETTING_RUNTIME_GRACE, $h, 1, 8760); }

    /** How often an install checks in with the mesh (minutes). */
    public static function heartbeatMinutes(): int { return self::num(self::SETTING_HEARTBEAT, (int) round(LicenseProtocol::HEARTBEAT_INTERVAL / 60), 5, 1440); }
    public static function setHeartbeatMinutes(int $m): bool { return self::setNum(self::SETTING_HEARTBEAT, $m, 5, 1440); }

    /** Per-call network timeout for mesh requests (seconds). */
    public static function networkTimeout(): int { return self::num(self::SETTING_NET_TIMEOUT, 12, 3, 120); }
    public static function setNetworkTimeout(int $s): bool { return self::setNum(self::SETTING_NET_TIMEOUT, $s, 3, 120); }

    /** Whether an install re-probes and adopts a moved server on failure. */
    public static function selfHeal(): bool
    {
        try { $v = PlatformSettings::get(self::SETTING_SELF_HEAL)['value'] ?? null; return $v === null ? true : (bool) $v; }
        catch (\Throwable $e) { return true; }
    }
    public static function setSelfHeal(bool $on): bool
    {
        try { return PlatformSettings::set(self::SETTING_SELF_HEAL, ['value' => $on ? 1 : 0]); }
        catch (\Throwable $e) { return false; }
    }

    /** Failed activation/renewal attempts from one IP before it is throttled. */
    public static function rateLimit(): int { return self::num(self::SETTING_RATE_LIMIT, 30, 3, 100000); }
    public static function setRateLimit(int $n): bool { return self::setNum(self::SETTING_RATE_LIMIT, $n, 3, 100000); }

    /** The window the failed-attempt count is measured over (minutes). */
    public static function rateWindow(): int { return self::num(self::SETTING_RATE_WINDOW, 10, 1, 1440); }
    public static function setRateWindow(int $m): bool { return self::setNum(self::SETTING_RATE_WINDOW, $m, 1, 1440); }

    /** Primary address clients call first. */
    public static function primary(): string
    {
        $v = trim((string) (PlatformSettings::get(self::SETTING_PRIMARY)['value'] ?? ''));
        if ($v !== '') return self::clean($v);
        // Not set by the owner → use the address the app DETECTED for itself, so a
        // fresh install ships activation codes with its real URL (with or without a
        // /public folder) and nobody has to type it. Only when there's no request to
        // read (CLI / cron) does the shipped default stand in.
        $self = class_exists(\PpPhp\Core\CoreConfig::class) ? \PpPhp\Core\CoreConfig::selfUrl() : '';
        return $self !== '' ? self::clean($self) : self::DEFAULT_PRIMARY;
    }

    public static function setPrimary(string $url): bool
    {
        return PlatformSettings::set(self::SETTING_PRIMARY, ['value' => self::clean($url)]);
    }

    /** @return array<int,string> backup addresses, in the order clients try them */
    public static function mirrors(): array
    {
        $raw = PlatformSettings::get(self::SETTING_MIRRORS)['value'] ?? [];
        if (!is_array($raw)) return [];
        $out = [];
        foreach ($raw as $u) {
            $u = self::clean((string) $u);
            if ($u !== '' && !in_array($u, $out, true)) $out[] = $u;
        }
        return $out;
    }

    /**
     * @param array<int,string> $urls
     * @return array{saved:array<int,string>, rejected:array<int,string>}
     */
    public static function setMirrors(array $urls): array
    {
        $saved = [];
        $rejected = [];
        foreach ($urls as $u) {
            $u = trim((string) $u);
            if ($u === '') continue;
            $c = self::clean($u);
            if ($c === '' || !self::isValid($c)) { $rejected[] = $u; continue; }
            if ($c === self::primary()) { $rejected[] = $u; continue; }   // not a backup of itself
            if (!in_array($c, $saved, true)) $saved[] = $c;
        }
        PlatformSettings::set(self::SETTING_MIRRORS, ['value' => $saved]);
        return ['saved' => $saved, 'rejected' => $rejected];
    }

    /**
     * The ordered list handed to clients: primary first, then mirrors.
     * Capped — a client should fail over, not spend a minute working through a
     * long list while somebody waits for a page to load.
     */
    public static function endpoints(): array
    {
        return array_slice(array_merge([self::primary()], self::distributableBackups()), 0, 6);
    }

    // ── Per-server display labels ──────────────────────────────────────
    // A friendly, vendor-chosen name a client shows for its licence server
    // instead of the raw URL (e.g. "SEC-SRV-NP"). Stored by normalised URL and
    // carried to clients INSIDE the signed lease (see issueLease `epl`), so a
    // label can't be spoofed and changing it reaches every install within a day.

    /** @return array<string,string> map of normalised-url => label */
    public static function labels(): array
    {
        $raw = PlatformSettings::get(self::SETTING_LABELS)['value'] ?? [];
        if (!is_array($raw)) return [];
        $out = [];
        foreach ($raw as $url => $label) {
            $u = self::clean((string) $url);
            $l = trim((string) $label);
            if ($u !== '' && $l !== '') $out[$u] = mb_substr($l, 0, 40);
        }
        return $out;
    }

    /** The label for one server URL, or '' if none is set. */
    public static function labelFor(string $url): string
    {
        return (string) (self::labels()[self::clean($url)] ?? '');
    }

    /**
     * Replace the whole label map. Blank labels remove that server's label.
     * @param array<string,string> $map url => label
     */
    public static function setLabels(array $map): bool
    {
        $clean = [];
        foreach ($map as $url => $label) {
            $u = self::clean((string) $url);
            $l = trim((string) $label);
            if ($u !== '' && $l !== '') $clean[$u] = mb_substr($l, 0, 40);
        }
        return PlatformSettings::set(self::SETTING_LABELS, ['value' => $clean]);
    }

    /** Set (or clear, with '') a single server's label, keeping the rest. */
    public static function setLabel(string $url, string $label): bool
    {
        $map = self::labels();
        $u = self::clean($url);
        if ($u === '') return false;
        $l = trim($label);
        if ($l === '') unset($map[$u]); else $map[$u] = mb_substr($l, 0, 40);
        return PlatformSettings::set(self::SETTING_LABELS, ['value' => $map]);
    }

    /**
     * Labels for just the endpoints a client will hold — the sparse map baked
     * into the lease. Only URLs that actually have a label are included.
     * @return array<string,string>
     */
    public static function endpointLabels(): array
    {
        $labels = self::labels();
        $out = [];
        foreach (self::endpoints() as $u) {
            if (isset($labels[$u])) $out[$u] = $labels[$u];
        }
        return $out;
    }

    /**
     * The full backup mesh handed to clients and synced to backups: every ACTIVE
     * paired backup (auto-discovered — no manual entry) plus any manually-added
     * mirror, de-duplicated and never including the primary itself. This is what
     * makes a freshly-paired backup instantly part of the estate.
     *
     * @return array<int,string>
     */
    public static function distributableBackups(): array
    {
        $out = [];
        foreach (array_merge(self::peerBackups(), self::mirrors()) as $u) {
            $u = self::clean((string) $u);
            if ($u !== '' && $u !== self::primary() && !in_array($u, $out, true)) $out[] = $u;
        }
        return $out;
    }

    /**
     * Public URLs of active paired backups, read straight from the peer table.
     * Empty (never fatal) on an install that has no peer table — e.g. a customer
     * client — so this is safe to call anywhere endpoints() is used.
     *
     * @return array<int,string>
     */
    public static function peerBackups(): array
    {
        try {
            $rows = \PpPhp\Core\DB::FetchAll("SELECT `url` FROM `pp_license_peers` WHERE `status` = 'active' AND `url` <> ''") ?: [];
        } catch (\Throwable $e) {
            return [];
        }
        $out = [];
        foreach ($rows as $r) {
            $u = self::clean((string) ($r['url'] ?? ''));
            if ($u !== '' && !in_array($u, $out, true)) $out[] = $u;
        }
        return $out;
    }

    /**
     * Refuse plaintext activation.
     *
     * Leases are signed, so plain HTTP cannot forge one — but an activation
     * request carries the customer's licence key in the body, and that should
     * never cross the network in the clear. On by default whenever the primary
     * address is https.
     */
    public static function requireHttps(): bool
    {
        $bag = PlatformSettings::get(self::SETTING_HTTPS);
        if (array_key_exists('value', $bag)) return (bool) $bag['value'];
        return str_starts_with(self::primary(), 'https://');
    }

    public static function setRequireHttps(bool $on): bool
    {
        return PlatformSettings::set(self::SETTING_HTTPS, ['value' => $on]);
    }

    /** True when the CURRENT request arrived over TLS (proxy-aware). */
    public static function requestIsSecure(): bool
    {
        if (!empty($_SERVER['HTTPS']) && strtolower((string) $_SERVER['HTTPS']) !== 'off') return true;
        if ((string) ($_SERVER['SERVER_PORT'] ?? '') === '443') return true;
        $proto = strtolower(trim((string) ($_SERVER['HTTP_X_FORWARDED_PROTO'] ?? '')));
        return str_starts_with($proto, 'https');
    }

    public static function isValid(string $url): bool
    {
        return (bool) preg_match('#^https?://[^\s/]+#i', $url) && parse_url($url, PHP_URL_HOST) !== null;
    }

    /** Normalise: trim, drop a trailing slash, strip any path noise a paste may carry. */
    public static function clean(string $url): string
    {
        $url = trim($url);
        if ($url === '') return '';
        $url = preg_replace('#/+(api/license.*)?$#i', '', $url) ?? $url;
        return rtrim($url, '/');
    }
}
