<?php

declare(strict_types=1);

namespace PpPhp\Core\Databases;

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

/**
 * Phase 4 — the backup pulls user data from the main and restores it locally.
 *
 * WHY THE BACKUP PULLS (this reads backwards at first glance):
 * the data is on the main, so "the main pushes" sounds right. But the main stores
 * only `sync_secret_hash` and never the secret itself, so it cannot authenticate
 * ITSELF to a backup — the credential only works backup → main, the direction it
 * was issued in. Pulling needs no new secret, no signing scheme, and no inbound
 * endpoint on a machine that could not verify the caller anyway, and it reuses the
 * one network path already proven through both hosts' egress rules (bug-035).
 *
 * WHAT MAKES THIS SAFE:
 *  • It only ever runs on a backup. A main calling this would be pulling its own
 *    data over itself, so `run()` refuses outright.
 *  • Every restore lands in a `replica_` database — `DbCloner::restore()` enforces
 *    that independently, so a bug here still cannot overwrite live user data.
 *  • Progress is recorded only AFTER a restore succeeds, so a failure retries next
 *    run instead of being marked done and skipped forever.
 *  • One database failing does not abort the batch — a single unreachable database
 *    must not stop every other one from being protected.
 */
final class DbReplicator
{
    /** Skip anything bigger than this per database; incremental sync is future work. */
    private const MAX_DUMP_BYTES = 64 * 1024 * 1024;

    /** @var (callable(string,array):array)|null */
    private $transport;

    /**
     * The transport is injectable — a `fn(string $action, array $payload): array`
     * — so the pull loop is testable without a second live server, and so this
     * class never needs to know how the main is reached.
     *
     * @param (callable(string,array):array)|null $transport
     */
    public function __construct(?callable $transport = null)
    {
        $this->transport = $transport;
    }

    /**
     * Pull every changed database from the main and restore it locally.
     *
     * @return array{success:bool,message:string,copied:int,skipped:int,failed:int,
     *               details:array<int,array{db:string,status:string,message:string}>}
     */
    public function run(): array
    {
        if (!ServerRole::isBackup()) {
            return self::fail('Database cloning runs on a backup server only — this one is the main.');
        }

        $call = $this->transport;
        if ($call === null) {
            $mirror = new LicenseMirror();
            if (!$mirror->isPaired()) {
                return self::fail('This backup is not paired with a main server yet.');
            }
            $call = static fn(string $a, array $p): array => $mirror->cloneCall($a, $p);
        }

        try {
            $manifest = $call('db-clone-manifest', []);
        } catch (\Throwable $e) {
            return self::fail('Could not ask the main server what it holds: ' . $e->getMessage());
        }

        $svc     = new ManagedDatabaseService();
        $details = [];
        $copied  = $skipped = $failed = 0;
        $tablesCopied = 0;

        foreach ((array) ($manifest['databases'] ?? []) as $entry) {
            $name   = (string) ($entry['db_name'] ?? '');
            $source = (array) ($entry['tables'] ?? []);
            if ($name === '') continue;
            // An EMPTY source is not "nothing to do": the replica may still hold
            // tables that have since been dropped, and the diff below removes them.
            // Skipping here would also hide the database from the totals entirely.

            // THE decision, and note where the comparison state comes from: the
            // REPLICA ITSELF, not a stored marker. Nothing to go stale, so dropping
            // the replica by hand, or losing a single table, self-heals on the next
            // run — the class of bug that produced bug-048 cannot recur here.
            $mine = $svc->replicaDigests($name);
            $d = \PpPhp\Core\Databases\TableDigest::diff($source, $mine);

            if ($d['copy'] === [] && $d['drop'] === []) {
                $skipped++;
                $details[] = ['db' => $name, 'status' => 'skipped',
                    'message' => $source === []
                        ? 'No tables yet — nothing to copy.'
                        : 'Identical — all ' . $d['same'] . ' table(s) match.'];
                continue;
            }

            $r = $this->cloneOne($call, $svc, $name, $d['copy'], $d['drop']);
            $details[] = ['db' => $name, 'status' => $r['success'] ? 'copied' : 'failed', 'message' => $r['message']];
            if ($r['success']) { $copied++; $tablesCopied += count($d['copy']); } else { $failed++; }
        }

        $msg = $copied . ' updated (' . $tablesCopied . ' table(s)), ' . $skipped . ' unchanged'
             . ($failed > 0 ? ', ' . $failed . ' failed' : '') . '.';

        return ['success' => $failed === 0, 'message' => $msg,
                'copied' => $copied, 'skipped' => $skipped, 'failed' => $failed,
                'tables' => $tablesCopied, 'details' => $details];
    }

    /**
     * Fetch, restore and record one database. Never throws — the caller's batch
     * must survive any single database going wrong.
     *
     * @return array{success:bool,message:string}
     */
    private function cloneOne(callable $call, ManagedDatabaseService $svc, string $name,
                             array $tables, array $drop): array
    {
        try {
            $payload = $call('db-clone-pull', ['db' => $name, 'tables' => $tables, 'drop' => $drop]);
        } catch (\Throwable $e) {
            return ['success' => false, 'message' => 'Could not fetch it: ' . $e->getMessage()];
        }

        if ((int) ($payload['bytes'] ?? 0) > self::MAX_DUMP_BYTES) {
            return ['success' => false, 'message' => 'Too large to clone in one piece ('
                . round(((int) $payload['bytes']) / 1048576, 1) . ' MB). Incremental cloning is not built yet.'];
        }

        $sql = @gzdecode((string) base64_decode((string) ($payload['gz'] ?? ''), true));
        if (!is_string($sql) || $sql === '') {
            return ['success' => false, 'message' => 'The dump arrived empty or corrupt — not restoring.'];
        }

        $target = $svc->ensureReplica($name);
        if (empty($target['success'])) {
            return ['success' => false, 'message' => (string) $target['message']];
        }

        $res = DbCloner::restore($target['conn'], $sql);
        if (empty($res['success'])) {
            return ['success' => false, 'message' => (string) $res['message']];
        }

        // Recorded only now, and using the fingerprint the MAIN took before it
        // dumped — so a write that landed mid-dump is not mistaken for cloned.
        $this->markLocal($name, (string) ($payload['fingerprint'] ?? ''), (int) ($payload['bytes'] ?? 0));

        $bits = count($tables) . ' table(s)';
        if ($drop !== []) $bits .= ', ' . count($drop) . ' removed';
        return ['success' => true, 'message' => 'Updated ' . $target['replica'] . ' — ' . $bits
            . ' (' . (int) ($payload['rows'] ?? 0) . ' rows).'];
    }

    /** Forget a clone marker, so the next run re-copies instead of skipping. */
    private function clearLocal(string $dbName): void
    {
        try {
            DB::Query("UPDATE `pp_managed_databases` SET `fingerprint` = '', `cloned_at` = NULL, `clone_bytes` = 0 WHERE `db_name` = ?", [$dbName]);
        } catch (\Throwable $e) {
            // Worst case the skip repeats; never fatal.
        }
    }

    private function markLocal(string $dbName, string $fingerprint, int $bytes): void
    {
        if ($fingerprint === '') return;
        try {
            DB::Query(
                'UPDATE `pp_managed_databases` SET `fingerprint` = ?, `cloned_at` = UTC_TIMESTAMP(), `clone_bytes` = ? WHERE `db_name` = ?',
                [$fingerprint, $bytes, $dbName]
            );
        } catch (\Throwable $e) {
            // Losing the marker only costs a redundant copy next run; never fatal.
        }
    }

    /** @return array{success:bool,message:string,copied:int,skipped:int,failed:int,details:array} */
    private static function fail(string $msg): array
    {
        return ['success' => false, 'message' => $msg, 'copied' => 0, 'skipped' => 0, 'failed' => 0, 'details' => []];
    }
}
