<?php

declare(strict_types=1);

namespace PpPhp\Core\Licensing;

use PpPhp\Core\Auth\OwnerCredentials;
use PpPhp\Core\DB;
use PpPhp\Core\PlatformSettings;

/**
 * The BACKUP licence server's side of replication.
 *
 * On install it PAIRS with the main (one authenticated call that brings down the
 * signing key, owner password, config and a first data snapshot), then re-pulls
 * a snapshot every 15 minutes. It is a read replica: it answers renewals from
 * the synced data but never issues licences. The main is the single writer.
 *
 * All of its own state (sync secret, last-sync time, main URL) lives in
 * storage/license/mirror.json — outside the code roots an update replaces — so
 * updating the platform never re-pairs or loses anything.
 */
final class LicenseMirror
{
    /** How often the backup re-pulls from the main. */
    public const SYNC_INTERVAL = 900;             // 15 minutes

    private string $file;
    private ?array $state = null;
    /** Per-candidate reach diagnostics from the last resolveMainUrl() probe. */
    private array $probeLog = [];
    /** @var array<string,array<string,true>> table → set of its actual column names (cached). */
    private array $columnCache = [];

    public function __construct(?string $basePath = null)
    {
        $basePath ??= defined('BASE_PATH') ? constant('BASE_PATH') : dirname(__DIR__, 2);
        $dir = rtrim($basePath, '/') . '/storage/license';
        if (!is_dir($dir)) @mkdir($dir, 0700, true);
        $this->file = $dir . '/mirror.json';
    }

    // ── State ─────────────────────────────────────────────────────────

    private function state(): array
    {
        if ($this->state === null) {
            $raw = is_file($this->file) ? (string) @file_get_contents($this->file) : '';
            $data = $raw !== '' ? json_decode($raw, true) : null;
            $this->state = is_array($data) ? $data : [];
        }
        return $this->state;
    }

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

    public function isPaired(): bool
    {
        $s = $this->state();
        return !empty($s['mainUrl']) && !empty($s['syncSecret']);
    }

    public function mainUrl(): string
    {
        return (string) ($this->state()['mainUrl'] ?? '');
    }

    // ── Pairing ───────────────────────────────────────────────────────

    /**
     * Pair with the main server using a one-time code. Brings down and installs
     * everything the backup needs.
     *
     * @throws LicenseException with a message safe to show
     */
    public function pair(string $mainUrl, string $code, string $label = '', string $selfUrl = ''): array
    {
        $raw = trim($mainUrl);
        if ($raw === '') {
            throw new LicenseException('Enter the main server address.');
        }
        if (trim($code) === '') {
            throw new LicenseException('Enter the pairing code from the main server.');
        }

        // Accept a bare domain or a full URL, with or without /public, http or
        // https — probe the variants and use whichever exposes the licence API.
        // The resolved base is what gets stored for future syncs.
        $mainUrl = $this->resolveMainUrl($raw);
        if ($mainUrl === '') {
            throw new LicenseException('Could not reach a PPPHP licence server at "' . $raw
                . '". Tried https and http, with and without the /public folder — check the address and that the main server is online.'
                . $this->probeDetail());
        }

        // Report our own public URL so the main auto-adds us to the mesh it hands
        // clients — the owner never types a mirror list by hand.
        $selfUrl = preg_match('#^https?://[^\s/]+#i', trim($selfUrl)) ? rtrim(trim($selfUrl), '/') : '';

        $resp = $this->post($mainUrl, 'pair', [
            'code'        => trim($code),
            'label'       => $label,
            'backupUrl'   => $selfUrl,
            'fingerprint' => Fingerprint::collect(),
        ]);

        $key = $resp['signingKey'] ?? null;
        if (!is_array($key) || empty($key['secret']) || empty($key['public'])) {
            throw new LicenseException('The main server did not return a signing key. Nothing was changed.');
        }

        // Install the signing key, config, owner password and first data snapshot.
        // Make it atomic: if applying config/snapshot fails, don't leave the backup
        // half-keyed. A fresh backup (no key before) is returned to keyless so the
        // pairing form stays available and the owner can simply retry with a new
        // code — instead of being stranded with the main's key but no pairing.
        $keys = new LicenseKeys();
        $hadKeyBefore = $keys->exists();
        $keys->importKey((string) $key['secret'], (string) $key['public']);
        try {
            $this->applyConfig((array) ($resp['config'] ?? []));
            $this->applySnapshot((array) ($resp['snapshot'] ?? []));
        } catch (\Throwable $e) {
            if (!$hadKeyBefore) {
                // roll the imported key back out so exists() is false again
                try { $keys->archive(); } catch (\Throwable $ignore) {}
            }
            throw ($e instanceof LicenseException)
                ? $e
                : new LicenseException('Pairing could not be completed: ' . $e->getMessage());
        }

        $this->save([
            'mainUrl'    => $mainUrl,
            'syncSecret' => (string) ($resp['syncSecret'] ?? ''),
            'pairedAt'   => time(),
            'lastSync'   => time(),
            'lastError'  => '',
            'keyId'      => (new LicenseKeys())->keyId(),
        ]);
        // Mark this install as a backup so its console reflects the role.
        ServerRole::setBackup($mainUrl);

        return $this->status();
    }

    /** Undo pairing (does not delete synced data — that stays valid until replaced). */
    public function unpair(): void
    {
        @unlink($this->file);
        $this->state = [];
    }

    /**
     * Check whether a main server is reachable and speaking the licence API,
     * WITHOUT pairing. Lets the operator confirm the address before entering a
     * one-time code. Interprets the same failure modes pairing would hit.
     *
     * @return array{ok:bool,message:string}
     */
    public function testMain(string $mainUrl): array
    {
        $raw = trim($mainUrl);
        if ($raw === '') {
            return ['ok' => false, 'message' => 'Enter the main server address first.'];
        }
        // Probe the variants (bare domain, /public, http/https) and use whichever
        // exposes the licence API.
        $resolved = $this->resolveMainUrl($raw);
        if ($resolved === '') {
            return ['ok' => false, 'message' => 'Could not reach a licence server at "' . $raw
                . '" — tried https and http, with and without the /public folder.'
                . $this->probeDetail()];
        }
        return ['ok' => true, 'message' => 'Reachable at ' . $resolved . ' — you can pair.', 'resolved' => $resolved];
    }

    // ── Sync ──────────────────────────────────────────────────────────

    public function syncDue(): bool
    {
        if (!$this->isPaired()) return false;
        $now = time();
        $last = (int) ($this->state()['lastSync'] ?? 0);
        if ($now - $last < self::SYNC_INTERVAL) return false;
        // back off between failed attempts so an unreachable main isn't hammered
        $lastTry = (int) ($this->state()['lastAttempt'] ?? 0);
        return ($now - $lastTry) >= 300;
    }

    /**
     * Pull a fresh snapshot from the main and apply it. Never throws — a failed
     * sync just means the backup keeps serving its last-synced data.
     */
    public function sync(): bool
    {
        if (!$this->isPaired()) return false;
        $this->save(['lastAttempt' => time()]);
        $secret = (string) $this->state()['syncSecret'];
        try {
            $resp = $this->post($this->mainUrl(), 'sync', ['syncSecret' => $secret]);
        } catch (LicenseException $e) {
            // Self-heal: the main may have moved WITHOUT changing hosts — most
            // often its document root was pointed at (or away from) the /public
            // front-controller folder, so the address we stored at pairing time no
            // longer resolves. Re-probe the variants of the address we already have
            // (same logic that made pairing "just work"); if a DIFFERENT one now
            // exposes the licence API, adopt it and retry once. The owner never has
            // to touch the backup — it re-finds the main on its own.
            // Honour the owner's self-heal toggle (default on).
            $selfHeal = !class_exists(LicenseConfig::class) || LicenseConfig::selfHeal();
            $healed = $selfHeal ? $this->reprobeMainUrl() : '';
            if ($healed === '' || $healed === $this->mainUrl()) {
                $this->save(['lastError' => $e->getMessage()]);
                return false;
            }
            $this->adoptMainUrl($healed);
            try {
                $resp = $this->post($healed, 'sync', ['syncSecret' => $secret]);
            } catch (LicenseException $e2) {
                $this->save(['lastError' => $e2->getMessage()]);
                return false;
            }
        }
        $this->applyConfig((array) ($resp['config'] ?? []));
        $this->applySnapshot((array) ($resp['snapshot'] ?? []));
        $this->save(['lastSync' => time(), 'lastError' => '', 'mainKeyId' => (string) ($resp['keyId'] ?? '')]);
        return true;
    }

    /**
     * Re-discover the main's working base address from the one we currently hold,
     * probing the usual variants (with/without /public, http/https). Returns the
     * first that exposes the licence API, or '' if none do (main genuinely down).
     */
    private function reprobeMainUrl(): string
    {
        $current = $this->mainUrl();
        return $current === '' ? '' : $this->resolveMainUrl($current);
    }

    /**
     * Persist a corrected main address in BOTH places that hold it — the mirror
     * state (used by sync/clone transport) and ServerRole (the console's "paired
     * to" display) — so a self-heal sticks and every path agrees.
     */
    private function adoptMainUrl(string $url): void
    {
        $url = rtrim(trim($url), '/');
        if ($url === '') return;
        $this->save(['mainUrl' => $url, 'healedAt' => time()]);
        try { ServerRole::setBackup($url); } catch (\Throwable $e) { /* display only */ }
    }

    /** Sync on an ordinary request if due — silent, rate-limited. */
    public static function tickIfDue(): void
    {
        if (!ServerRole::isBackup()) return;
        try {
            $m = new self();
            if ($m->syncDue()) $m->sync();
        } catch (\Throwable $e) {
            // never let a background sync break a page load
        }
    }

    // ── Applying what the main sent ───────────────────────────────────

    private function applyConfig(array $config): void
    {
        if ($config === []) return;
        try {
            if (isset($config['primary']))      LicenseConfig::setPrimary((string) $config['primary']);
            if (isset($config['mirrors']) && is_array($config['mirrors'])) LicenseConfig::setMirrors($config['mirrors']);
            if (isset($config['requireHttps']))  LicenseConfig::setRequireHttps((bool) $config['requireHttps']);
            if (isset($config['ownerMode']))     OwnerCredentials::setMode((string) $config['ownerMode']);
            if (isset($config['deriveVersion']))
                PlatformSettings::set(OwnerDerivation::SETTING_VERSION, ['value' => (int) $config['deriveVersion']]);
            if (!empty($config['ownerHash']))    OwnerCredentials::applyHash((string) $config['ownerHash']);

            // Update host READ side — so a fresh backup's update feed can reach the
            // signed index without any manual setup. Credentials never travel; the
            // backup only reads over https and never publishes.
            if (isset($config['updateHost']) && is_array($config['updateHost'])
                && class_exists('\PpPhp\Core\Update\UpdateHostConfig')) {
                $existing = PlatformSettings::get(\PpPhp\Core\Update\UpdateHostConfig::SETTING)['value'] ?? [];
                $existing = is_array($existing) ? $existing : [];
                $existing['publicUrl'] = rtrim((string) ($config['updateHost']['publicUrl'] ?? ''), '/');
                if (!empty($config['updateHost']['channel'])) $existing['channel'] = (string) $config['updateHost']['channel'];
                PlatformSettings::set(\PpPhp\Core\Update\UpdateHostConfig::SETTING, ['value' => $existing]);
            }
        } catch (\Throwable $e) {
            // config drift is non-fatal
        }
    }

    /**
     * Create every table the snapshot apply writes to — BEFORE the transaction.
     *
     * CRITICAL (MySQL): schema creation is DDL, and DDL forces an IMPLICIT COMMIT
     * even for `CREATE TABLE IF NOT EXISTS` that changes nothing. If any of these
     * ran inside applySnapshot()'s transaction, that implicit commit would end the
     * transaction mid-flight and the closing commit()/rollBack() would throw
     * "There is no active transaction" — which is exactly what stopped a fresh
     * backup from pairing (and from every 15-minute sync). Keeping all DDL here,
     * outside the transaction, leaves only DELETE/INSERT (DML) inside it.
     */
    private function ensureReplicaSchema(): void
    {
        LicenseServer::ensureSchema();
        if (class_exists(\PpPhp\Core\Databases\ManagedDatabaseService::class)) {
            try { \PpPhp\Core\Databases\ManagedDatabaseService::ensureSchemaStatic(); } catch (\Throwable $e) {}
        }
        if (class_exists(\PpPhp\Core\Licensing\PlanRegistry::class)) {
            try { \PpPhp\Core\Licensing\PlanRegistry::ensureSchema(); } catch (\Throwable $e) {}
        }
        if (class_exists(\PpPhp\Core\Update\AdvisoryStore::class)) {
            try { \PpPhp\Core\Update\AdvisoryStore::ensureSchema(); } catch (\Throwable $e) {}
        }
        // Virtual-database catalogue tables — a backup may never have used the VDB
        // engine locally, so its catalogue tables can be absent. Create them before
        // the apply transaction (same DDL-outside-transaction rule as above).
        if (class_exists(\PpPhp\Core\Databases\VirtualDbService::class)) {
            try { (new \PpPhp\Core\Databases\VirtualDbService())->ensureSchema(); } catch (\Throwable $e) {}
        }
        // Projects catalogue tables. `projects` itself is created by the installer,
        // but ensure it (and its satellites) for a defensive fresh backup.
        try {
            DB::Exec(
                "CREATE TABLE IF NOT EXISTS `projects` (
                    `id` INT AUTO_INCREMENT PRIMARY KEY,
                    `name` VARCHAR(100) NOT NULL,
                    `slug` VARCHAR(100) NOT NULL UNIQUE,
                    `description` TEXT,
                    `db_host` VARCHAR(100), `db_port` INT DEFAULT 3306,
                    `db_name` VARCHAR(100), `db_user` VARCHAR(100), `db_pass` VARCHAR(255),
                    `created_by` INT,
                    `created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
                    `updated_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
                 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4"
            );
        } catch (\Throwable $e) {}
        if (class_exists(\PpPhp\Core\ProjectCatalog::class)) {
            try { \PpPhp\Core\ProjectCatalog::ensureSchema(); } catch (\Throwable $e) {}
        }
        if (class_exists(\PpPhp\Core\Projects\ProjectVersionCatalog::class)) {
            try { \PpPhp\Core\Projects\ProjectVersionCatalog::ensureSchema(); } catch (\Throwable $e) {}
        }
        // Deployment tables (deploy_connections / deploy_db_connections /
        // deploy_builds / deploy_uploads / deploy_upload_files). They FK into
        // `projects`, so they are wiped by the projects cascade every apply and must
        // be re-created + re-inserted or the backup loses the owner's deploy configs.
        if (class_exists(\PpPhp\Core\Deploy\DeploySchema::class)) {
            try { \PpPhp\Core\Deploy\DeploySchema::ensureSchema(); } catch (\Throwable $e) {}
        }
    }

    /**
     * Replace the local licence data with the main's snapshot. Full replace
     * inside a transaction: the main is authoritative, and this correctly
     * reflects deletions (a licence removed on the main disappears here too).
     */
    private function applySnapshot(array $snap): void
    {
        $licenses  = is_array($snap['licenses'] ?? null) ? $snap['licenses'] : null;
        $instances = is_array($snap['instances'] ?? null) ? $snap['instances'] : null;
        if ($licenses === null && $instances === null) return;

        // Create EVERY table the apply touches BEFORE opening the transaction.
        // See ensureReplicaSchema(): schema creation is DDL, and on MySQL any DDL
        // (even CREATE TABLE IF NOT EXISTS that changes nothing) forces an implicit
        // COMMIT — which, if it ran inside the transaction below, would silently end
        // it and make the final commit() throw "There is no active transaction".
        // That is exactly what made pairing/sync fail on a fresh backup.
        $this->ensureReplicaSchema();
        $pdo = DB::Pdo();
        try {
            $pdo->beginTransaction();
            if ($licenses !== null) {
                $pdo->exec('DELETE FROM `pp_licenses`');
                foreach ($licenses as $row) $this->insertRow('pp_licenses', (array) $row);
            }
            if ($instances !== null) {
                $pdo->exec('DELETE FROM `pp_license_instances`');
                foreach ($instances as $row) $this->insertRow('pp_license_instances', (array) $row);
            }

            $this->applyControlPlane($snap, $pdo);

            $pdo->commit();
        } catch (\Throwable $e) {
            if ($pdo->inTransaction()) $pdo->rollBack();
            throw new LicenseException('Could not apply the synced licence data: ' . $e->getMessage());
        }
    }

    /**
     * Apply the control-plane tables: managed databases, accounts, trial queue.
     *
     * Runs inside the caller's transaction so a partial apply can never be left
     * behind. Each table is skipped unless its key is present, so an older main
     * cannot empty a table it simply does not know about.
     */
    private function applyControlPlane(array $snap, \PDO $pdo): void
    {
        // ── managed databases ──────────────────────────────────────────
        //
        // THE TRAP: `server_id` is a local auto-increment on each side. Copying
        // it blind would point this backup's catalogue rows at whatever server
        // happens to hold that id here — a different panel, or nothing at all.
        // So the main sends its servers WITH identity (driver + host), and every
        // id is translated through that map before it is stored.
        $mdbs = is_array($snap['managedDatabases'] ?? null) ? $snap['managedDatabases'] : null;
        if ($mdbs !== null) {
            // Schema already ensured before the transaction (see ensureReplicaSchema);
            // never run CREATE TABLE here — DDL implicit-commits the transaction.
            $map = $this->serverIdMap(is_array($snap['dbServers'] ?? null) ? $snap['dbServers'] : []);

            // Clone state belongs to THIS side (what we have actually restored), and
            // the rebuild below would drop it. Carry it across, keyed by database
            // name, so a catalogue sync never resets replication progress — and so
            // an older main that still sends these columns cannot overwrite ours.
            $keep = [];
            foreach (DB::FetchAll('SELECT `db_name`,`fingerprint`,`cloned_at`,`clone_bytes` FROM `pp_managed_databases`') ?: [] as $k) {
                $keep[(string) $k['db_name']] = $k;
            }

            $pdo->exec('DELETE FROM `pp_managed_databases`');
            foreach ($mdbs as $row) {
                $row = (array) $row;
                $mine = $keep[(string) ($row['db_name'] ?? '')] ?? null;
                unset($row['fingerprint'], $row['cloned_at'], $row['clone_bytes']);
                if ($mine !== null) {
                    $row['fingerprint'] = $mine['fingerprint'];
                    $row['cloned_at']   = $mine['cloned_at'];
                    $row['clone_bytes'] = $mine['clone_bytes'];
                }
                $remote = (int) ($row['server_id'] ?? 0);
                $local  = $map[$remote] ?? 0;
                // A database we cannot place is still worth recording — the owner
                // needs to know it exists — but it must not silently attach to the
                // wrong server, so unplaceable rows are skipped rather than guessed.
                if ($local <= 0) continue;
                $row['server_id'] = $local;
                $this->insertRow('pp_managed_databases', $row);
            }
        }

        // ── licence plans + owner advisories ───────────────────────────
        // Sensitive, owner-authored licence data that must survive shifting to a
        // backup / new server. Their tables (pp_license_plans, pp_advisories) are
        // created BEFORE the transaction in ensureReplicaSchema() — creating them
        // here would be DDL inside the transaction and would implicit-commit it.
        // Replicated straight below — no per-side columns to translate.

        // ── accounts + trial queue + plans + advisories ────────────────
        // Replicated so the backup is a usable console (sign in, same people) and
        // holds the same licence catalogue. Order matters: users before anything
        // referencing them.
        // Order matters — a PARENT is replaced before anything that references it,
        // so an INSERT never hits a missing foreign key and a cascade-on-DELETE only
        // clears rows we are about to re-insert:
        //   users → projects → project satellites   (projects.created_by → users;
        //     project_catalog / project_vcs_settings.project_id → projects CASCADE)
        //   users → virtual DBs → shares/quotas      (shares.db_id → pp_virtual_databases)
        // project_versions and the deploy_* tables FK into `projects` (and
        // project_versions also into `users`), so they must be applied AFTER both —
        // and deploy_uploads.connection_id → deploy_connections, so connections come
        // before uploads. Without these here the projects cascade wipes them on the
        // backup every sync and they never return until failover.
        foreach ([
            ['users', 'users'],
            ['trialRequests', 'temporary_access_requests'],
            ['plans', 'pp_license_plans'],
            ['advisories', 'pp_advisories'],
            ['projects', 'projects'],
            ['projectCatalog', 'project_catalog'],
            ['projectVcs', 'project_vcs_settings'],
            ['projectVersions', 'project_versions'],
            ['deployConnections', 'deploy_connections'],
            ['deployDbConnections', 'deploy_db_connections'],
            ['deployBuilds', 'deploy_builds'],
            ['deployUploads', 'deploy_uploads'],
            ['virtualDbs', 'pp_virtual_databases'],
            ['virtualDbShares', 'pp_virtual_db_shares'],
            ['virtualDbQuotas', 'pp_virtual_db_quotas'],
        ] as [$key, $table]) {
            $rows = is_array($snap[$key] ?? null) ? $snap[$key] : null;
            if ($rows === null) continue;
            // Never empty a parent data table on the strength of an empty payload —
            // that is far more likely to be a main that hiccuped than a genuine
            // "delete all". (Advisories/trial queue legitimately reach zero, so they
            // are not guarded and may be cleared.)
            if ($rows === [] && in_array($key,
                ['users', 'plans', 'projects', 'virtualDbs', 'virtualDbShares', 'virtualDbQuotas'], true)) {
                continue;
            }
            $pdo->exec('DELETE FROM `' . $table . '`');
            foreach ($rows as $row) $this->insertRow($table, (array) $row);
        }
    }

    /**
     * Translate the main's `pp_db_servers` ids to this side's.
     *
     * Matching is on (driver, host) because that is what actually identifies a
     * panel; labels are cosmetic and ids are per-side. A server the backup does
     * not have is created locally as METADATA ONLY and left `disabled`: it never
     * carries a credential (those are per-side by design), so it must never be
     * selectable for provisioning here.
     *
     * @param array<int,array<string,mixed>> $remoteServers
     * @return array<int,int> remote id => local id
     */
    private function serverIdMap(array $remoteServers): array
    {
        $local = DB::FetchAll('SELECT `id`, `host`, `driver`, `is_platform` FROM `pp_db_servers`') ?: [];
        $byKey = [];
        $platformId = 0;
        foreach ($local as $s) {
            $byKey[strtolower(trim((string) $s['driver'])) . '|' . strtolower(trim((string) $s['host']))] = (int) $s['id'];
            if ((int) ($s['is_platform'] ?? 0) === 1) $platformId = (int) $s['id'];
        }

        $map = [];
        foreach ($remoteServers as $r) {
            $r = (array) $r;
            $remoteId = (int) ($r['id'] ?? 0);
            if ($remoteId <= 0) continue;

            // The platform server exists on both sides but with different
            // host/id — match it by role, not address.
            if ((int) ($r['is_platform'] ?? 0) === 1 && $platformId > 0) {
                $map[$remoteId] = $platformId;
                continue;
            }

            $key = strtolower(trim((string) ($r['driver'] ?? 'sql'))) . '|' . strtolower(trim((string) ($r['host'] ?? '')));
            if (isset($byKey[$key])) { $map[$remoteId] = $byKey[$key]; continue; }

            try {
                DB::Query(
                    'INSERT INTO `pp_db_servers`
                        (`label`,`host`,`port`,`is_platform`,`grant_host`,`status`,`driver`,`api_port`,`db_host`,`name_prefix`,`admin_user`)
                     VALUES (?,?,?,0,?,?,?,?,?,?,?)',
                    [
                        mb_substr((string) ($r['label'] ?? $r['host'] ?? 'replicated'), 0, 120),
                        (string) ($r['host'] ?? ''), (int) ($r['port'] ?? 3306),
                        (string) ($r['grant_host'] ?? '%'),
                        'disabled',                       // no credential here — cannot provision
                        (string) ($r['driver'] ?? 'sql'), (int) ($r['api_port'] ?? 0),
                        (string) ($r['db_host'] ?? ''), (string) ($r['name_prefix'] ?? ''),
                        (string) ($r['admin_user'] ?? ''),
                    ]
                );
                $newId = (int) DB::Pdo()->lastInsertId();
                if ($newId > 0) { $map[$remoteId] = $newId; $byKey[$key] = $newId; }
            } catch (\Throwable $e) {
                // A server we cannot record just means its databases are skipped.
            }
        }
        return $map;
    }

    /**
     * The columns a backup table ACTUALLY has, cached per table.
     *
     * A newer main can send a column this backup's table predates; inserting it
     * blind throws and aborts the whole apply transaction, freezing replication.
     * insertRow() intersects every row against this so an unknown column is dropped,
     * not fatal. SHOW COLUMNS works identically on MySQL and MariaDB.
     *
     * @return array<string,true>
     */
    private function existingColumns(string $table): array
    {
        if (isset($this->columnCache[$table])) return $this->columnCache[$table];
        $cols = [];
        try {
            foreach (DB::FetchAll('SHOW COLUMNS FROM `' . $table . '`') ?: [] as $c) {
                $name = (string) ($c['Field'] ?? '');
                if ($name !== '') $cols[$name] = true;
            }
        } catch (\Throwable $e) {
            // Unknown table / no introspection — leave empty; insertRow then keeps
            // the row as-is (original behaviour) rather than silently dropping data.
        }
        return $this->columnCache[$table] = $cols;
    }

    /** Insert a row with an explicit id (a replica keeps the main's ids). */
    private function insertRow(string $table, array $row): void
    {
        if ($row === []) return;
        // Drop any key the backup's table does not have, so a newer main sending a
        // column this schema predates can't abort the whole apply transaction. Only
        // filter when the columns are actually known — an empty set means we could
        // not introspect, and the row is left intact to preserve happy-path inserts.
        $known = $this->existingColumns($table);
        if ($known !== []) {
            $row = array_intersect_key($row, $known);
            if ($row === []) return;
        }
        $cols = array_keys($row);
        $ph   = implode(', ', array_fill(0, count($cols), '?'));
        $sql  = 'INSERT INTO `' . $table . '` (`' . implode('`, `', $cols) . '`) VALUES (' . $ph . ')';
        DB::Query($sql, array_values($row));
    }

    // ── Status (for the UI) ───────────────────────────────────────────

    public function status(): array
    {
        $s = $this->state();
        return [
            'paired'    => $this->isPaired(),
            'mainUrl'   => (string) ($s['mainUrl'] ?? ''),
            'keyId'     => (string) ($s['keyId'] ?? ''),
            'pairedAt'  => (int) ($s['pairedAt'] ?? 0),
            'lastSync'  => (int) ($s['lastSync'] ?? 0),
            'lastError' => (string) ($s['lastError'] ?? ''),
            'interval'  => self::SYNC_INTERVAL,
        ];
    }

    // ── Transport ─────────────────────────────────────────────────────

    /**
     * Find the real console base of a main server from whatever the owner typed —
     * a bare domain, a full URL, with or without the `/public` front-controller
     * folder, http or https. It probes the likely variants and returns the first
     * that actually exposes the licence API, so `ppphp.ppradhan.com` and
     * `https://ppphp.ppradhan.com/public` both "just work" (also for customer
     * activation). Returns '' if nothing answered.
     */
    private function resolveMainUrl(string $input): string
    {
        $this->probeLog = [];
        foreach ($this->candidateUrls($input) as $c) {
            if ($this->apiResponds($c)) return $c;
        }
        return '';
    }

    /**
     * Human-readable reason(s) the probes failed, for the "could not reach" errors.
     * De-duplicates the underlying cURL/HTTP messages (all variants usually fail the
     * same way — e.g. DNS) so the operator sees the actual cause, not four lines.
     */
    private function probeDetail(): string
    {
        if ($this->probeLog === []) return '';
        $reasons = array_values(array_unique(array_map('strval', $this->probeLog)));
        return $reasons === [] ? '' : ' Reason: ' . implode('; ', $reasons) . '.';
    }

    /**
     * Ordered address variants to try for a typed-in main server. Most-likely
     * first: https + /public, then https bare, then the http equivalents. Honours
     * a scheme or /public the owner already typed (tried first), then fills in the
     * rest.
     *
     * @return array<int,string>
     */
    private function candidateUrls(string $input): array
    {
        $input = rtrim(trim($input), '/');
        if ($input === '') return [];

        $scheme = '';
        if (preg_match('#^(https?)://#i', $input, $m)) { $scheme = strtolower($m[1]); $input = substr($input, strlen($m[0])); }
        $host = rtrim($input, '/');                       // host + optional path
        if ($host === '' || !preg_match('#^[a-z0-9.\-]+(:\d+)?(/.*)?$#i', $host)) return [];

        // Path variants — prefer /public (the common front-controller layout).
        $stripped = preg_replace('#/public$#i', '', $host);
        $paths = array_values(array_unique(array_filter([
            preg_match('#/public$#i', $host) ? $host : $host . '/public',
            $host,
            $stripped,
        ], static fn ($p) => $p !== '')));

        // Scheme order — what they typed first (if any), then https, then http.
        $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));
    }

    /**
     * True if <base>/api/license/pair exists (any answer other than 404 / no-connect).
     * Records WHY each attempt failed in $this->probeLog so the caller can tell the
     * operator DNS-failure vs connection-refused vs cert-error vs 404 — instead of a
     * blanket "could not reach".
     */
    private function apiResponds(string $base): bool
    {
        $base = rtrim($base, '/');
        if (!function_exists('curl_init')) {
            $this->probeLog[$base] = 'cURL is disabled on this server (enable the php-curl extension)';
            return false;
        }
        $ch = curl_init($base . '/api/license/pair');
        curl_setopt_array($ch, [
            CURLOPT_POST => true, CURLOPT_POSTFIELDS => '{}',
            CURLOPT_HTTPHEADER => ['Content-Type: application/json'],
            CURLOPT_RETURNTRANSFER => true,
            CURLOPT_CONNECTTIMEOUT => 6, CURLOPT_TIMEOUT => 10, CURLOPT_FOLLOWLOCATION => false,
            CURLOPT_USERAGENT => 'PPPHP-Mirror/1',
        ]);
        curl_exec($ch);
        $code = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
        $err  = trim((string) curl_error($ch));
        unset($ch);
        if ($code === 0)   { $this->probeLog[$base] = $err !== '' ? $err : 'no response (connection failed or timed out)'; return false; }
        if ($code === 404) { $this->probeLog[$base] = 'HTTP 404 — no licence API at this path'; return false; }
        return true;   // reachable
    }


    /**
     * Call a data-plane endpoint on the paired main, authenticated with this
     * backup's sync secret.
     *
     * Exposed so `DbReplicator` can reuse the one place that knows how to reach
     * the main (URL probing, curl-or-streams fallback, error shape) instead of
     * growing a second, subtly different transport. The timeout is much larger
     * than the licence sync's: this carries a whole database, not a few rows.
     *
     * @param array<string,mixed> $payload
     * @return array<string,mixed>
     */
    public function cloneCall(string $action, array $payload): array
    {
        return $this->post(
            $this->mainUrl(),
            $action,
            $payload + ['syncSecret' => (string) ($this->state()['syncSecret'] ?? '')],
            180
        );
    }

    private function post(string $baseUrl, string $action, array $payload, int $timeout = 30): array
    {
        $url  = rtrim($baseUrl, '/') . '/api/license/' . $action;
        $body = (string) json_encode($payload, JSON_UNESCAPED_SLASHES);
        $raw  = false; $code = 0; $err = '';

        if (function_exists('curl_init')) {
            $ch = curl_init($url);
            curl_setopt_array($ch, [
                CURLOPT_POST => true, CURLOPT_POSTFIELDS => $body,
                CURLOPT_HTTPHEADER => ['Content-Type: application/json', 'Accept: application/json'],
                CURLOPT_RETURNTRANSFER => true, CURLOPT_CONNECTTIMEOUT => 8, CURLOPT_TIMEOUT => $timeout,
                CURLOPT_FOLLOWLOCATION => false, CURLOPT_USERAGENT => 'PPPHP-Mirror/1',
            ]);
            $raw = curl_exec($ch);
            $code = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
            $err = curl_error($ch);
            unset($ch);
        } else {
            $ctx = stream_context_create(['http' => [
                'method' => 'POST', 'header' => "Content-Type: application/json\r\nAccept: application/json\r\n",
                'content' => $body, 'timeout' => $timeout, 'ignore_errors' => true,
            ]]);
            $raw = @file_get_contents($url, false, $ctx);
            foreach ($http_response_header ?? [] as $h) {
                if (preg_match('#^HTTP/\S+\s+(\d{3})#', $h, $m)) $code = (int) $m[1];
            }
        }

        if ($raw === false || $code === 0) {
            throw new LicenseException('Could not reach the main server at ' . rtrim($baseUrl, '/') . ($err !== '' ? " ({$err})" : '.'));
        }
        $data = json_decode((string) $raw, true);
        if (!is_array($data)) {
            $host = rtrim($baseUrl, '/');
            $hint = $code === 404
                ? "The main server at {$host} has no pairing endpoint (HTTP 404). Two usual causes: it is running an "
                    . 'older PPPHP version — update it to this version first; or this is not its console base URL — '
                    . 'use the exact address you log in to the main at (including any subfolder such as /public).'
                : "The main server at {$host} did not answer as a PPPHP licence server (HTTP {$code}).";
            throw new LicenseException($hint);
        }
        if ($code >= 400 || empty($data['success'])) {
            throw new LicenseException((string) ($data['message'] ?? 'The main server refused the request.'), $code ?: 400);
        }
        return $data;
    }
}
