<?php

declare(strict_types=1);

namespace PpPhp\Core\Replication;

use PpPhp\Core\Licensing\LicenseMirror;
use PpPhp\Core\Licensing\ServerRole;

/**
 * The backup's side of ON-DISK replication: it pulls the main's virtual-database
 * files and project trees and lays them down locally, so a backup holds not just
 * the same accounts and licences but the same actual data and app designs.
 *
 * It is the file-set twin of DbReplicator (which moves server MySQL databases):
 * same direction (backup PULLS — only the backup holds the sync secret), same
 * transport (LicenseMirror::cloneCall), same "never throw, one bad item never
 * aborts the batch, progress recorded only after success" discipline.
 *
 * Change detection:
 *   • 'copy' sets  — the backup hashes its OWN files and pulls only the ones whose
 *                    hash differs from the main's manifest. A hand-deleted or
 *                    corrupted file self-heals, with no stored state.
 *   • 'snapshot'   — VACUUM copies are not byte-identical to the live file, so the
 *                    backup remembers, per file, the SOURCE hash it last pulled
 *                    (storage/replication/files.json) and re-pulls when the main's
 *                    source hash changes.
 * Files present locally but absent from the manifest are deleted (mirroring), but
 * only ever inside the set root and only files the manifest rules would include.
 */
final class FileReplicator
{
    /** Copy-mode files larger than this are pulled in ranges to bound memory. */
    private const CHUNK = 8 * 1024 * 1024;               // 8 MB

    /** Hard ceiling on a single assembled file (matches ReplicationSets). */
    private const MAX_FILE = ReplicationSets::MAX_FILE_BYTES;

    /** @var callable(string,array):array */
    private $transport;
    private string $stateFile;
    private array $state;

    /**
     * @param callable(string,array):array|null $transport test seam; defaults to
     *        the paired mirror's authenticated cloneCall.
     */
    public function __construct(?callable $transport = null, ?string $basePath = null)
    {
        $base = rtrim($basePath ?? (defined('BASE_PATH') ? (string) constant('BASE_PATH') : dirname(__DIR__, 2)), '/');
        $dir  = $base . '/storage/replication';
        if (!is_dir($dir)) { @mkdir($dir, 0700, true); }
        $this->stateFile = $dir . '/files.json';
        $raw = is_file($this->stateFile) ? (string) @file_get_contents($this->stateFile) : '';
        $decoded = $raw !== '' ? json_decode($raw, true) : null;
        $this->state = is_array($decoded) ? $decoded : [];

        if ($transport !== null) {
            $this->transport = $transport;
        } else {
            $mirror = new LicenseMirror($basePath);
            $this->transport = static fn (string $action, array $payload): array => $mirror->cloneCall($action, $payload);
        }
    }

    /**
     * Pull every set. Backup-only. Returns a per-set summary; never throws.
     *
     * @return array{success:bool,message:string,sets:array<string,array<string,mixed>>}
     */
    public function run(): array
    {
        if (!ServerRole::isBackup()) {
            return ['success' => false, 'message' => 'File replication runs on a backup server only — this one is the main.', 'sets' => []];
        }
        $sets = [];
        $pulled = $deleted = $failed = 0;
        foreach (ReplicationSets::all() as $set) {
            $r = $this->runSet($set);
            $sets[$set] = $r;
            $pulled  += (int) $r['pulled'];
            $deleted += (int) $r['deleted'];
            $failed  += (int) $r['failed'];
        }
        $this->save();
        $ok = ($failed === 0);
        return [
            'success' => $ok,
            'message' => $ok
                ? "Files in sync ({$pulled} updated, {$deleted} removed)."
                : "Replicated with {$failed} failure(s) ({$pulled} updated, {$deleted} removed) — will retry next run.",
            'sets'    => $sets,
        ];
    }

    /** @return array<string,mixed> */
    private function runSet(string $set): array
    {
        $mode = ReplicationSets::mode($set);
        $markers = is_array($this->state[$set] ?? null) ? $this->state[$set] : [];
        $pulled = $deleted = $failed = 0;

        try {
            $manifest = ($this->transport)('set-manifest', ['set' => $set]);
        } catch (\Throwable $e) {
            return ['pulled' => 0, 'deleted' => 0, 'failed' => 1, 'error' => 'manifest: ' . $e->getMessage()];
        }
        $files = is_array($manifest['files'] ?? null) ? $manifest['files'] : [];

        // Local state: for 'copy' we hash our own files; for 'snapshot' we rely on markers.
        $local = ($mode === 'copy') ? ReplicationSets::manifest($set)['files'] : [];

        foreach ($files as $rel => $meta) {
            $rel  = (string) $rel;
            $sha  = (string) ($meta['sha256'] ?? '');
            $size = (int) ($meta['size'] ?? 0);
            if ($sha === '' || ReplicationSets::safeRel($rel) === null) {
                continue;
            }
            $have = ($mode === 'copy')
                ? (string) ($local[$rel]['sha256'] ?? '')
                : (string) ($markers[$rel] ?? '');
            if ($have === $sha) {
                continue; // already current
            }
            try {
                $bytes = $this->pullFile($set, $rel, $size, $mode);
                if ($bytes === null) { $failed++; continue; }
                if (!ReplicationSets::writeFile($set, $rel, $bytes)) { $failed++; continue; }
                if ($mode === 'snapshot') { $markers[$rel] = $sha; }
                $pulled++;
            } catch (\Throwable $e) {
                $failed++;
            }
        }

        // Mirror deletions: anything we hold under this set that the main no longer
        // lists. Use the SAME include rules (manifest()) so guard files are never
        // considered, and only delete when the manifest actually arrived.
        if (!empty($files) || $manifest['files'] === []) {
            $mineNow = ReplicationSets::manifest($set)['files'];
            foreach ($mineNow as $rel => $_meta) {
                if (!isset($files[$rel])) {
                    ReplicationSets::deleteFile($set, (string) $rel);
                    unset($markers[$rel]);
                    $deleted++;
                }
            }
        }

        $this->state[$set] = $markers;
        return ['pulled' => $pulled, 'deleted' => $deleted, 'failed' => $failed, 'files' => count($files)];
    }

    /**
     * Pull one file whole (snapshot, or small copy) or in ranges (large copy).
     * Returns the raw bytes, or null on transport/verify failure.
     */
    private function pullFile(string $set, string $rel, int $size, string $mode): ?string
    {
        if ($size > self::MAX_FILE) {
            return null; // refused — matches the DB-clone single-piece cap
        }
        if ($mode === 'snapshot' || $size <= self::CHUNK) {
            $r = ($this->transport)('set-pull', ['set' => $set, 'path' => $rel]);
            return $this->decode($r);
        }
        // Large copy file — assemble from ranges.
        $buf = '';
        $offset = 0;
        $guard = 0;
        while ($offset < $size) {
            if (++$guard > 100000) { return null; }
            $r = ($this->transport)('set-pull', ['set' => $set, 'path' => $rel, 'offset' => $offset, 'len' => self::CHUNK]);
            $chunk = $this->decode($r);
            if ($chunk === null || $chunk === '') { return null; }
            $buf .= $chunk;
            $offset += strlen($chunk);
            if (strlen($buf) > self::MAX_FILE) { return null; }
            if (!empty($r['eof'])) { break; }
        }
        return $buf;
    }

    /** Decode a set-pull payload ({gz: base64(gzip(bytes))}). */
    private function decode(array $r): ?string
    {
        $gz = (string) ($r['gz'] ?? '');
        if ($gz === '') { return null; }
        $raw = @gzdecode((string) base64_decode($gz, true));
        return $raw === false ? null : $raw;
    }

    private function save(): void
    {
        @file_put_contents($this->stateFile, (string) json_encode($this->state, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES), LOCK_EX);
        @chmod($this->stateFile, 0600);
    }
}
