<?php

declare(strict_types=1);

namespace PpPhp\Core\Replication;

use PpPhp\Core\Databases\VirtualDb;

/**
 * The catalogue of on-disk "file sets" that a backup mirrors from the main, and
 * every path-safety rule that keeps that mirroring from being turned into a file
 * read/write primitive by a compromised or buggy caller.
 *
 * A SET is a rooted subtree under BASE_PATH with an include filter and a copy
 * MODE:
 *   • 'copy'     — plain files copied byte-for-byte (project designs, uploads).
 *                  Change detection compares the backup's own file hash to the
 *                  main's, so a hand-deleted file self-heals with no extra state.
 *   • 'snapshot' — live SQLite pool databases. A raw copy of a WAL-mode database
 *                  can be torn, so the main hands over a VACUUM INTO copy, which
 *                  is a consistent single file but NOT byte-identical to the live
 *                  one — so change detection is marker-based (FileReplicator
 *                  remembers the source hash it last pulled), not file-vs-file.
 *
 * Everything here is static and side-effect-free except the explicit read/write
 * helpers, so both the main (serving) and the backup (applying) share ONE set of
 * rules — the roots, the excludes and the traversal guard can never drift apart.
 */
final class ReplicationSets
{
    /** A single pulled blob is refused above this — matches the DB-clone cap. */
    public const MAX_FILE_BYTES = 64 * 1024 * 1024;      // 64 MB

    /** Safety cap so a pathological tree can never make a manifest unbounded. */
    private const MAX_FILES = 200000;

    /** Test seam only: point the set roots at a specific base instead of BASE_PATH. */
    private static ?string $baseOverride = null;

    /** @internal test seam */
    public static function useBase(?string $base): void
    {
        self::$baseOverride = ($base === null) ? null : rtrim(str_replace('\\', '/', $base), '/');
    }

    private static function base(): string
    {
        if (self::$baseOverride !== null) {
            return self::$baseOverride;
        }
        return rtrim(str_replace('\\', '/', defined('BASE_PATH') ? (string) constant('BASE_PATH') : dirname(__DIR__, 2)), '/');
    }

    /** The set catalogue: name => {rel root, mode, human label}. */
    public static function defs(): array
    {
        return [
            // Virtual database pool — the *.sqlite files, consistent VACUUM copies.
            // Snapshots (storage/vdb/_snapshots) are per-side history and never travel.
            'vdb' => ['rel' => 'storage/vdb', 'mode' => 'snapshot', 'label' => 'Virtual databases'],
            // Every user's project design tree (config/*.json incl. secrets, the
            // in-tree versions/ history, and custom/ files).
            'projects' => ['rel' => 'projects', 'mode' => 'copy', 'label' => 'Projects'],
        ];
    }

    /** @return list<string> */
    public static function all(): array
    {
        return array_keys(self::defs());
    }

    public static function isValid(string $set): bool
    {
        return isset(self::defs()[$set]);
    }

    public static function mode(string $set): string
    {
        return (string) (self::defs()[$set]['mode'] ?? 'copy');
    }

    /** Absolute root directory of a set, or '' for an unknown set. */
    public static function root(string $set): string
    {
        $d = self::defs()[$set] ?? null;
        return $d ? self::base() . '/' . $d['rel'] : '';
    }

    // ── Path safety ───────────────────────────────────────────────────

    /**
     * Normalise and vet a set-relative path. Returns the cleaned forward-slashed
     * relative path, or null if it is empty, absolute, contains a "." / ".."
     * segment, a NUL byte, or a backslash — i.e. anything that could escape the
     * set root. The ONLY paths this returns are safe to append to the root.
     */
    public static function safeRel(string $rel): ?string
    {
        $rel = str_replace('\\', '/', trim($rel));
        if ($rel === '' || $rel[0] === '/' || str_contains($rel, "\0")) {
            return null;
        }
        $out = [];
        foreach (explode('/', $rel) as $seg) {
            if ($seg === '' || $seg === '.' || $seg === '..') {
                return null;
            }
            $out[] = $seg;
        }
        return implode('/', $out);
    }

    /**
     * Resolve a set + relative path to an absolute path, GUARANTEED to sit under
     * the set root (symlinks in the final component are rejected). Null on any
     * violation. This is the single choke point every read/write goes through.
     */
    public static function absOf(string $set, string $rel): ?string
    {
        $root = self::root($set);
        $safe = self::safeRel($rel);
        if ($root === '' || $safe === null) {
            return null;
        }
        $abs = $root . '/' . $safe;
        // Belt-and-braces: the real path (when it exists) must be inside the real
        // root. realpath() collapses any symlink trickery the segment check missed.
        $realRoot = realpath($root);
        if ($realRoot === false) {
            return $abs; // root not created yet (fresh backup) — segment check stands
        }
        $realRoot = rtrim(str_replace('\\', '/', $realRoot), '/');
        $realAbs  = realpath($abs);
        if ($realAbs !== false) {
            $realAbs = str_replace('\\', '/', $realAbs);
            if ($realAbs !== $realRoot && !str_starts_with($realAbs, $realRoot . '/')) {
                return null;
            }
        }
        return $abs;
    }

    // ── Enumeration + manifest ────────────────────────────────────────

    /**
     * Should this relative path travel? Excludes the platform-managed guard files
     * (recreated locally by PrivatePathGuard/VirtualDb), transient UI state, editor
     * backups, and — for vdb — the per-side snapshot history.
     */
    private static function included(string $set, string $rel): bool
    {
        $base = basename($rel);
        if (in_array($base, ['.htaccess', 'web.config', 'index.html', '_resume.json'], true)) {
            return false;
        }
        if (str_contains($base, '.bak-') || str_ends_with($base, '.bak')) {
            return false;
        }
        if ($set === 'vdb') {
            // Only the top-level pool databases; never the _snapshots/ subtree.
            if (str_contains($rel, '/')) {
                return false;
            }
            return str_ends_with($rel, '.sqlite');
        }
        return true;
    }

    /**
     * Build the manifest for a set: relative-path => {sha256, size}. sha256 is
     * hash_file (streamed, low memory). Symlinks are skipped — their targets are
     * platform-shipped, so the backup already has them. Directories are implicit.
     *
     * @return array{base:string, mode:string, files:array<string,array{sha256:string,size:int}>, truncated:bool}
     */
    public static function manifest(string $set): array
    {
        $root = self::root($set);
        $mode = self::mode($set);
        $out  = ['base' => (string) (self::defs()[$set]['rel'] ?? ''), 'mode' => $mode, 'files' => [], 'truncated' => false];
        if ($root === '' || !is_dir($root)) {
            return $out;
        }
        $seen = 0;
        $it = new \RecursiveIteratorIterator(
            new \RecursiveDirectoryIterator($root, \FilesystemIterator::SKIP_DOTS | \FilesystemIterator::CURRENT_AS_FILEINFO),
            \RecursiveIteratorIterator::LEAVES_ONLY
        );
        foreach ($it as $f) {
            if (++$seen > self::MAX_FILES) { $out['truncated'] = true; break; }
            if (!$f->isFile() || $f->isLink()) {
                continue;
            }
            $rel = ltrim(str_replace('\\', '/', substr($f->getPathname(), strlen($root))), '/');
            if ($rel === '' || !self::included($set, $rel)) {
                continue;
            }
            $sha = @hash_file('sha256', $f->getPathname());
            if ($sha === false) {
                continue;
            }
            $out['files'][$rel] = ['sha256' => $sha, 'size' => (int) $f->getSize()];
        }
        return $out;
    }

    // ── Reading (main side) ───────────────────────────────────────────

    /**
     * Read the whole of a 'copy' file, or a VACUUM-consistent copy of a 'snapshot'
     * (SQLite) file, as raw bytes. Null if missing/unsafe/oversized.
     */
    public static function readWhole(string $set, string $rel): ?string
    {
        $abs = self::absOf($set, $rel);
        if ($abs === null || !is_file($abs)) {
            return null;
        }
        if (self::mode($set) === 'snapshot') {
            // Consistent single-file copy of a live WAL-mode SQLite database.
            $tmp = self::tmpPath();
            try {
                VirtualDb::backupTo($abs, $tmp);          // VACUUM INTO
                if (!is_file($tmp) || filesize($tmp) > self::MAX_FILE_BYTES) {
                    return null;
                }
                $data = @file_get_contents($tmp);
                return $data === false ? null : $data;
            } catch (\Throwable $e) {
                return null;
            } finally {
                if (isset($tmp) && is_file($tmp)) { @unlink($tmp); }
            }
        }
        if (filesize($abs) > self::MAX_FILE_BYTES) {
            return null;
        }
        $data = @file_get_contents($abs);
        return $data === false ? null : $data;
    }

    /** Read a byte range of a 'copy' file (for streaming large uploads in chunks). */
    public static function readRange(string $set, string $rel, int $offset, int $len): ?string
    {
        if (self::mode($set) === 'snapshot') {
            return self::readWhole($set, $rel); // snapshots are always whole
        }
        $abs = self::absOf($set, $rel);
        if ($abs === null || !is_file($abs)) {
            return null;
        }
        $offset = max(0, $offset);
        $len    = max(1, min($len, self::MAX_FILE_BYTES));
        $data = @file_get_contents($abs, false, null, $offset, $len);
        return $data === false ? null : $data;
    }

    // ── Writing (backup side) ─────────────────────────────────────────

    /**
     * Atomically write a replicated file: temp file in the same directory, then
     * rename over the target (so a reader never sees a half-written file), then
     * tighten the mode of the two load-bearing secrets. Returns false on any
     * unsafe path or IO failure.
     */
    public static function writeFile(string $set, string $rel, string $bytes): bool
    {
        $abs = self::absOf($set, $rel);
        if ($abs === null) {
            return false;
        }
        $dir = dirname($abs);
        if (!is_dir($dir) && !@mkdir($dir, 0775, true) && !is_dir($dir)) {
            return false;
        }
        $tmp = $dir . '/.repl-' . bin2hex(random_bytes(6)) . '.tmp';
        if (@file_put_contents($tmp, $bytes, LOCK_EX) === false) {
            @unlink($tmp);
            return false;
        }
        if (!@rename($tmp, $abs)) {
            @unlink($tmp);
            return false;
        }
        // Match the source's restrictive modes for the two secrets.
        $base = basename($abs);
        if ($base === 'encryption.key') {
            @chmod($abs, 0600);
        } elseif ($base === 'database.php') {
            @chmod($abs, 0640);
        }
        // A freshly-created pool db directory must stay web-denied.
        if ($set === 'vdb') {
            VirtualDb::poolDir();
        }
        return true;
    }

    /** Delete a replicated file (mirroring a deletion on the main). Root-fenced. */
    public static function deleteFile(string $set, string $rel): void
    {
        $abs = self::absOf($set, $rel);
        if ($abs === null || !is_file($abs)) {
            return;
        }
        if ($set === 'vdb') {
            VirtualDb::deleteFile($abs);     // also clears -wal/-shm/-journal
            return;
        }
        @unlink($abs);
    }

    private static function tmpPath(): string
    {
        $dir = self::base() . '/storage/replication/staging';
        if (!is_dir($dir)) { @mkdir($dir, 0700, true); }
        return $dir . '/vac-' . bin2hex(random_bytes(8)) . '.sqlite';
    }
}
