<?php

declare(strict_types=1);

namespace PpPhp\Core;

/**
 * The DNS-like failover engine for the PPPHP server mesh.
 *
 * A primary and any number of secondaries all hold the same signed licence data,
 * updates and access — so ANY of them can answer a read. This class is the one
 * place that decides, for a given request, WHICH order to try them in, so that a
 * dead or slow server is never felt: like a resolver walking a list of name
 * servers, it puts the last-known-good server first, cools down ones that just
 * failed, pushes a hairpin (a host that resolves to this very server's own IP) to
 * the back, and — crucially — NEVER drops a candidate, so as long as one server in
 * the estate is up, the operation succeeds.
 *
 * It holds health as a tiny local file (no DB dependency) and is used by every
 * client↔server transport (licence activate/renew, updates, builds, mesh sync),
 * so all of them fail over identically instead of each reinventing it.
 *
 * Ships to clients unchanged; holds no secrets.
 */
final class MeshResolver
{
    /** A server that just failed is de-prioritised for this long (seconds). */
    private const COOLDOWN = 300;
    /** A success within this window marks a server "hot" and floats it to the top. */
    private const HOT = 3600;
    /** Re-resolve a host's IP (for hairpin detection) at most this often. */
    private const IP_TTL = 86400;

    private static ?array $cache = null;

    private static function file(): string
    {
        $base = defined('BASE_PATH') ? (string) constant('BASE_PATH') : getcwd();
        $dir = rtrim($base, '/') . '/storage';
        if (!is_dir($dir)) @mkdir($dir, 0775, true);
        return $dir . '/mesh-health.json';
    }

    /** @return array<string,array<string,mixed>> */
    private static function state(): array
    {
        if (self::$cache !== null) return self::$cache;
        $raw = @file_get_contents(self::file());
        $d = $raw !== false ? json_decode($raw, true) : null;
        return self::$cache = is_array($d) ? $d : [];
    }

    private static function persist(array $state): void
    {
        self::$cache = $state;
        @file_put_contents(self::file(), json_encode($state, JSON_UNESCAPED_SLASHES), LOCK_EX);
    }

    private static function norm(string $url): string
    {
        return rtrim(trim($url), '/');
    }

    /**
     * Ordered address variants to try for a base URL that stopped answering —
     * with/without a `/public` front-controller folder, http/https. Most-likely
     * first (what's already there, then the /public toggle, then http). Used by the
     * client to SELF-HEAL when a stored server address goes stale (e.g. a document
     * root moved), the same idea that makes pairing "just work". Pure generation —
     * no network — so it ships to clients and is trivially testable.
     *
     * @return list<string>
     */
    public static function variants(string $url): array
    {
        $in = rtrim(trim($url), '/');
        if ($in === '') return [];
        $scheme = '';
        if (preg_match('#^(https?)://#i', $in, $m)) { $scheme = strtolower($m[1]); $in = substr($in, strlen($m[0])); }
        $host = rtrim($in, '/');
        if ($host === '' || !preg_match('#^[a-z0-9.\-]+(:\d+)?(/.*)?$#i', $host)) return [];

        $stripped = (string) preg_replace('#/public$#i', '', $host);
        $paths = array_values(array_unique(array_filter([
            $host,                                               // exactly as stored (tried first)
            preg_match('#/public$#i', $host) ? '' : $host . '/public',
            $stripped,
        ], static fn ($p) => $p !== '')));
        $schemes = $scheme !== '' ? array_values(array_unique([$scheme, 'https', 'http'])) : ['https', 'http'];

        $out = [];
        foreach ($schemes as $s) {
            foreach ($paths as $p) $out[] = $s . '://' . $p;
        }
        return array_values(array_unique($out));
    }

    /**
     * Order candidate server base URLs best-first for THIS request. Deduplicates,
     * but never removes — the worst candidate still gets a turn last, so a total
     * outage of all-but-one still resolves.
     *
     * @param list<string> $candidates
     * @return list<string>
     */
    public static function order(array $candidates, ?int $now = null): array
    {
        $now ??= time();
        $state = self::state();
        $seen = [];
        $items = [];
        $i = 0;
        foreach ($candidates as $c) {
            $u = self::norm((string) $c);
            if ($u === '' || isset($seen[$u])) continue;
            $seen[$u] = true;
            $h = $state[$u] ?? [];
            $items[] = ['url' => $u, 'idx' => $i++, 'score' => self::score($u, $h, $now)];
        }
        // Descending score; original order breaks ties so a fresh estate keeps the
        // owner's intended primary-first ordering.
        usort($items, static fn ($a, $b) => ($b['score'] <=> $a['score']) ?: ($a['idx'] <=> $b['idx']));
        return array_map(static fn ($x) => $x['url'], $items);
    }

    /** Health score for one server; higher = try sooner. */
    private static function score(string $url, array $h, int $now): int
    {
        $score = 0;
        $ok   = (int) ($h['ok'] ?? 0);
        $fail = (int) ($h['fail'] ?? 0);
        $fails = (int) ($h['fails'] ?? 0);

        if ($ok > 0)                       $score += 100;                 // has worked before
        if ($ok > 0 && $now - $ok < self::HOT) $score += 60;             // worked recently → hot
        if ($fails > 0 && $now - $fail < self::COOLDOWN) {              // just failed → cool down
            $score -= 150 * min($fails, 5);
        }
        if (self::isHairpin($url, $h))     $score -= 500;                // its own IP — a server can't reach itself
        return $score;
    }

    /** Record that a server answered — floats it to the front next time. */
    public static function recordSuccess(string $url): void
    {
        $u = self::norm($url);
        if ($u === '') return;
        $state = self::state();
        $h = $state[$u] ?? [];
        $h['ok'] = time();
        $h['fails'] = 0;                                                 // reset the failure streak
        $state[$u] = $h;
        self::persist($state);
    }

    /** Record that a server did not answer — cools it down for a while. */
    public static function recordFailure(string $url): void
    {
        $u = self::norm($url);
        if ($u === '') return;
        $state = self::state();
        $h = $state[$u] ?? [];
        $h['fail'] = time();
        $h['fails'] = (int) ($h['fails'] ?? 0) + 1;
        $state[$u] = $h;
        self::persist($state);
    }

    /**
     * Is this URL's host really this same server (a hairpin that outbound requests
     * usually can't traverse)? Resolves the host's IP at most once per IP_TTL and
     * caches it in the health file, comparing to this server's own address. Best
     * effort — an unknown answer is treated as "not a hairpin" so we never wrongly
     * bury a good server.
     */
    public static function isHairpin(string $url, ?array $cached = null): bool
    {
        $self = trim((string) ($_SERVER['SERVER_ADDR'] ?? ''));
        if ($self === '') return false;
        $host = (string) parse_url(self::norm($url), PHP_URL_HOST);
        if ($host === '') return false;

        $now = time();
        $ip = '';
        if ($cached !== null && !empty($cached['ip']) && ($now - (int) ($cached['ipAt'] ?? 0) < self::IP_TTL)) {
            $ip = (string) $cached['ip'];
        } else {
            $r = @gethostbyname($host);
            if ($r !== $host && filter_var($r, FILTER_VALIDATE_IP)) {
                $ip = $r;
                $state = self::state();
                $u = self::norm($url);
                $h = $state[$u] ?? [];
                $h['ip'] = $ip; $h['ipAt'] = $now;
                $state[$u] = $h;
                self::persist($state);
            }
        }
        return $ip !== '' && $ip === $self;
    }
}
