<?php

declare(strict_types=1);

namespace PpPhp\Core\Update;

use PpPhp\Core\Deploy\TransportFactory;
use PpPhp\Core\Deploy\TransportInterface;
use PpPhp\Core\PlatformSettings;

/**
 * Where the update authority PUBLISHES release files, and where clients DOWNLOAD
 * them from. Owner-managed (admin-ppradhan) from the update-engine console.
 *
 * Two halves, deliberately separate:
 *   • the WRITE side — an FTP/FTPS/SFTP connection the owner uses to upload a
 *     freshly built bundle + deltas from inside the app (reuses the Deploy
 *     module's transports for Test Connection and upload);
 *   • the READ side — the PUBLIC https base URL a client fetches a bundle from
 *     (a client can't FTP; it downloads over the web). The signed feed hands
 *     clients a full URL built from this base, so switching hosts re-points the
 *     whole estate with nothing to re-enter on the customer side.
 *
 * This class only stores + shapes the settings; access control lives in the
 * controller, exactly as \PpPhp\Core\Licensing\LicenseConfig does.
 */
final class UpdateHostConfig
{
    public const SETTING = 'platform.update_host';

    /** @return array<string,mixed> the raw stored config (secrets included) */
    private static function raw(): array
    {
        $v = PlatformSettings::get(self::SETTING)['value'] ?? [];
        if (!is_array($v)) return [];
        // Self-heal a historically over-wrapped value: a bad "restore" once nested
        // the bag as {value:{value:{value:config}}}. Unwrap extra 'value' layers
        // until we reach the real config (identified by host/protocol), so a
        // corrupted setting reads correctly instead of looking "not configured".
        $guard = 0;
        while (!isset($v['host']) && !isset($v['protocol'])
               && isset($v['value']) && is_array($v['value']) && $guard < 8) {
            $v = $v['value'];
            $guard++;
        }
        return is_array($v) ? $v : [];
    }

    public static function protocol(): string
    {
        $p = strtolower((string) (self::raw()['protocol'] ?? 'ftp'));
        return in_array($p, ['ftp', 'ftps', 'sftp'], true) ? $p : 'ftp';
    }

    public static function host(): string { return trim((string) (self::raw()['host'] ?? '')); }
    public static function port(): int
    {
        $p = (int) (self::raw()['port'] ?? 0);
        return $p > 0 ? $p : TransportFactory::defaultPort(self::protocol());
    }
    public static function user(): string { return (string) (self::raw()['user'] ?? ''); }

    /** Absolute base directory on the host that bundles are uploaded into. */
    public static function remoteDir(): string
    {
        $d = trim((string) (self::raw()['remoteDir'] ?? ''));
        return $d !== '' ? '/' . trim($d, '/') : '';
    }

    /** Sub-folders the engine organises artifacts into, under the base directory. */
    public static function fullDir(): string  { return self::remoteDir() . '/full'; }
    public static function deltaDir(): string { return self::remoteDir() . '/delta'; }

    /** The host sub-folder for a given artifact kind. */
    public static function dirFor(string $kind): string
    {
        return $kind === 'delta' ? self::deltaDir() : self::fullDir();
    }

    /** Public https base URL that maps to remoteDir (what clients download from). */
    public static function publicUrl(): string
    {
        return rtrim(trim((string) (self::raw()['publicUrl'] ?? '')), '/');
    }

    public static function channel(): string
    {
        $c = strtolower((string) (self::raw()['channel'] ?? 'stable'));
        return in_array($c, ['stable', 'beta'], true) ? $c : 'stable';
    }

    /**
     * A friendly, vendor-chosen name for the update service, shown to clients in
     * place of the raw host URLs (e.g. in a failure message). Carried to clients
     * inside the signed lease (see LicenseServer::issueLease `udn`). Blank = the
     * client uses a neutral generic term instead of exposing any address.
     */
    public const SETTING_DISPLAY = 'platform.update_display_name';

    public static function displayName(): string
    {
        return trim((string) (PlatformSettings::get(self::SETTING_DISPLAY)['value'] ?? ''));
    }

    public static function setDisplayName(string $name): bool
    {
        return PlatformSettings::set(self::SETTING_DISPLAY, ['value' => mb_substr(trim($name), 0, 40)]);
    }

    /** True once the owner has entered enough to publish AND serve downloads. */
    public static function isConfigured(): bool
    {
        return self::host() !== '' && self::remoteDir() !== '' && self::publicUrl() !== '';
    }

    /**
     * The public download URL for a bundle filename, in its kind's sub-folder
     * (full/ or delta/). Clients fetch from here.
     */
    public static function downloadUrl(string $filename, string $kind = 'full'): string
    {
        $base = self::publicUrl();
        if ($base === '') return '';
        $sub = $kind === 'delta' ? 'delta' : 'full';
        return $base . '/' . $sub . '/' . ltrim($filename, '/');
    }

    /**
     * Map a stored public download URL back to its path on the FTP host, so a
     * published artifact can be removed when its release is deleted. Works for
     * both the current full/ + delta/ layout and any older flat URL, because it
     * simply swaps the public base for the remote base and keeps the tail.
     *
     * Returns null when the URL does not belong to the currently-configured
     * public base (a host change, or an externally-hosted URL) — refusing to
     * guess rather than risk deleting the wrong file.
     */
    public static function remotePathForUrl(string $url): ?string
    {
        $base = self::publicUrl();
        $dir  = self::remoteDir();
        $url  = trim($url);
        if ($base === '' || $dir === '' || $url === '') return null;
        if (!str_starts_with($url, $base . '/')) return null;
        return $dir . substr($url, strlen($base));   // e.g. remoteDir + "/full/bundle-1.0.3.zip"
    }

    /**
     * The SECONDARY host path for an artifact whose PRIMARY public URL is $url.
     * Catalogue URLs are always the primary's, but the same file lives under the
     * secondary's upload dir (same full/ or delta/ sub-path) — so we take the
     * sub-path from the primary mapping and re-root it at the secondary dir. Lets
     * "Delete release" clean BOTH hosts. Null when there is no secondary or the
     * URL doesn't belong to the primary.
     */
    public static function secondaryRemotePathForUrl(string $url): ?string
    {
        if (!self::hasSecondary()) return null;
        $primaryPath = self::remotePathForUrl($url);
        if ($primaryPath === null || $primaryPath === '') return null;
        $primaryDir = self::remoteDir();                 // /primaryDir
        $suffix = $primaryPath;
        if ($primaryDir !== '' && str_starts_with($primaryPath, $primaryDir)) {
            $suffix = substr($primaryPath, strlen($primaryDir));   // /full/bundle.zip
        }
        $suffix = '/' . ltrim($suffix, '/');
        return rtrim(self::secondaryRemoteDir(), '/') . $suffix;
    }

    /**
     * A profile shaped for \PpPhp\Core\Deploy\TransportFactory::make(). Carries
     * the real secret, so this is only ever used server-side for the actual
     * connection — never sent to a browser.
     *
     * @return array<string,mixed>
     */
    public static function transportProfile(): array
    {
        $r = self::raw();
        return [
            'name'           => 'Update host',
            'protocol'       => self::protocol(),
            'host'           => self::host(),
            'port'           => self::port(),
            'user'           => self::user(),
            'pass'           => (string) ($r['pass'] ?? ''),
            'remotePath'     => self::remoteDir(),
            'passive'        => (bool) ($r['passive'] ?? true),
            'privateKeyFile' => (string) ($r['privateKeyFile'] ?? ''),
            'privateKeyPass' => (string) ($r['privateKeyPass'] ?? ''),
            'timeout'        => 20,
        ];
    }

    /** Build a live transport for Test Connection / upload. */
    public static function transport(): TransportInterface
    {
        return TransportFactory::make(self::transportProfile());
    }

    // ── Secondary FTP host (optional fallback mirror) ──────────────────
    /** @return array<string,mixed> */
    public static function secondary(): array
    {
        $s = self::raw()['secondary'] ?? [];
        return is_array($s) ? $s : [];
    }

    /** A secondary host counts only when it has the three essentials. */
    public static function hasSecondary(): bool
    {
        $s = self::secondary();
        return trim((string) ($s['host'] ?? '')) !== ''
            && trim((string) ($s['remoteDir'] ?? '')) !== ''
            && trim((string) ($s['publicUrl'] ?? '')) !== '';
    }

    public static function secondaryPublicUrl(): string
    {
        return rtrim(trim((string) (self::secondary()['publicUrl'] ?? '')), '/');
    }

    public static function secondaryRemoteDir(): string
    {
        $d = trim((string) (self::secondary()['remoteDir'] ?? ''));
        return $d !== '' ? '/' . trim($d, '/') : '';
    }

    /** @return array<string,mixed> */
    public static function secondaryTransportProfile(): array
    {
        $s = self::secondary();
        $protocol = strtolower((string) ($s['protocol'] ?? 'ftp'));
        if (!in_array($protocol, ['ftp', 'ftps', 'sftp'], true)) $protocol = 'ftp';
        $port = (int) ($s['port'] ?? 0);
        return [
            'name'           => 'Secondary host',
            'protocol'       => $protocol,
            'host'           => trim((string) ($s['host'] ?? '')),
            'port'           => $port > 0 ? $port : TransportFactory::defaultPort($protocol),
            'user'           => (string) ($s['user'] ?? ''),
            'pass'           => (string) ($s['pass'] ?? ''),
            'remotePath'     => self::secondaryRemoteDir(),
            'passive'        => (bool) ($s['passive'] ?? true),
            'privateKeyFile' => (string) ($s['privateKeyFile'] ?? ''),
            'privateKeyPass' => (string) ($s['privateKeyPass'] ?? ''),
            'timeout'        => 20,
        ];
    }

    public static function secondaryTransport(): TransportInterface
    {
        return TransportFactory::make(self::secondaryTransportProfile());
    }

    /** Public URL of a secondary artifact (mirrors downloadUrl for slot 2). */
    public static function secondaryDownloadUrl(string $filename, string $kind = 'full'): string
    {
        $base = self::secondaryPublicUrl();
        if ($base === '') return '';
        $sub = $kind === 'delta' ? 'delta' : 'full';
        return $base . '/' . $sub . '/' . ltrim($filename, '/');
    }

    // ── Google Drive backup (optional, best-effort off-site copy) ──────
    /** @return array<string,mixed> */
    public static function gdrive(): array
    {
        $g = self::raw()['gdrive'] ?? [];
        return is_array($g) ? $g : [];
    }

    /** Enabled + points at a connected Google Drive storage connection. */
    public static function hasGdrive(): bool
    {
        $g = self::gdrive();
        return !empty($g['enabled']) && trim((string) ($g['connection'] ?? '')) !== '';
    }

    public static function gdriveConnectionId(): string
    {
        return trim((string) (self::gdrive()['connection'] ?? ''));
    }

    /** The public index URL a client can read from Drive — recorded after a publish. */
    public static function gdriveIndexUrl(): string
    {
        return trim((string) (self::gdrive()['indexUrl'] ?? ''));
    }

    /** Persist the Drive index/file-id map after a backup upload. */
    public static function setGdriveState(string $indexUrl, array $fileIds): void
    {
        $raw = self::raw();
        $g = is_array($raw['gdrive'] ?? null) ? $raw['gdrive'] : [];
        $g['indexUrl'] = $indexUrl;
        $g['fileIds']  = $fileIds;
        $raw['gdrive'] = $g;
        PlatformSettings::set(self::SETTING, ['value' => $raw]);
    }

    // ── Aggregated index URLs for the client: primary → secondary → drive ──
    /** @return array<int,string> */
    public static function indexUrls(): array
    {
        $urls = [];
        if (self::publicUrl() !== '')      $urls[] = self::publicUrl() . '/index.json';
        if (self::hasSecondary())          $urls[] = self::secondaryPublicUrl() . '/index.json';
        if (self::gdriveIndexUrl() !== '') $urls[] = self::gdriveIndexUrl();
        return array_values(array_unique(array_filter($urls)));
    }

    /**
     * A transport profile from owner-supplied (possibly unsaved) form values, so
     * Test Connection works before Save. A blank secret falls back to the stored
     * one — the form never shows the password, so the owner shouldn't have to
     * retype it just to test. Server-side only (carries the real secret).
     *
     * @param array<string,mixed> $in
     * @return array<string,mixed>
     */
    public static function transportProfileFrom(array $in): array
    {
        // Slot-aware: the secondary host form carries slot=secondary, so a blank
        // secret falls back to the SECONDARY's saved credentials, never the
        // primary's (which would make a secondary Test/Verify use the wrong login).
        $slot   = (($in['slot'] ?? '') === 'secondary') ? 'secondary' : 'primary';
        $stored = $slot === 'secondary' ? self::secondary() : self::raw();
        $protocol = strtolower(trim((string) ($in['protocol'] ?? ($stored['protocol'] ?? 'ftp'))));
        if (!in_array($protocol, ['ftp', 'ftps', 'sftp'], true)) $protocol = 'ftp';
        $pass = (string) ($in['pass'] ?? '');
        if ($pass === '') $pass = (string) ($stored['pass'] ?? '');
        $keyPass = (string) ($in['privateKeyPass'] ?? '');
        if ($keyPass === '') $keyPass = (string) ($stored['privateKeyPass'] ?? '');
        $port = (int) ($in['port'] ?? 0);
        $dir = trim((string) ($in['remoteDir'] ?? (string) ($stored['remoteDir'] ?? '')));
        return [
            'name'           => $slot === 'secondary' ? 'Secondary host' : 'Update host',
            'protocol'       => $protocol,
            'host'           => trim((string) ($in['host'] ?? ($stored['host'] ?? ''))),
            'port'           => $port > 0 ? $port : TransportFactory::defaultPort($protocol),
            'user'           => (string) ($in['user'] ?? ($stored['user'] ?? '')),
            'pass'           => $pass,
            'remotePath'     => $dir !== '' ? '/' . trim($dir, '/') : '',
            'passive'        => (bool) ($in['passive'] ?? ($stored['passive'] ?? true)),
            'privateKeyFile' => (string) ($in['privateKeyFile'] ?? ($stored['privateKeyFile'] ?? '')),
            'privateKeyPass' => $keyPass,
            'timeout'        => 20,
        ];
    }

    /**
     * For the console: everything the form needs, with secrets redacted to a
     * boolean "is set" flag so a password is never echoed back into HTML.
     *
     * @return array<string,mixed>
     */
    public static function forDisplay(): array
    {
        $r = self::raw();
        return [
            'protocol'      => self::protocol(),
            'host'          => self::host(),
            'port'          => (int) ($r['port'] ?? 0),
            'user'          => self::user(),
            'remoteDir'     => (string) ($r['remoteDir'] ?? ''),
            'publicUrl'     => self::publicUrl(),
            'channel'       => self::channel(),
            'passive'       => (bool) ($r['passive'] ?? true),
            'privateKeyFile' => (string) ($r['privateKeyFile'] ?? ''),
            'hasPass'       => (string) ($r['pass'] ?? '') !== '',
            'hasKeyPass'    => (string) ($r['privateKeyPass'] ?? '') !== '',
            'configured'    => self::isConfigured(),
        ];
    }

    /** Secondary host, shaped for the owner form (secrets masked). @return array<string,mixed> */
    public static function secondaryForDisplay(): array
    {
        $s = self::secondary();
        $protocol = strtolower((string) ($s['protocol'] ?? 'ftp'));
        if (!in_array($protocol, ['ftp', 'ftps', 'sftp'], true)) $protocol = 'ftp';
        return [
            'protocol'       => $protocol,
            'host'           => trim((string) ($s['host'] ?? '')),
            'port'           => (int) ($s['port'] ?? 0),
            'user'           => (string) ($s['user'] ?? ''),
            'remoteDir'      => (string) ($s['remoteDir'] ?? ''),
            'publicUrl'      => (string) ($s['publicUrl'] ?? ''),
            'passive'        => (bool) ($s['passive'] ?? true),
            'privateKeyFile' => (string) ($s['privateKeyFile'] ?? ''),
            'hasPass'        => (string) ($s['pass'] ?? '') !== '',
            'configured'     => self::hasSecondary(),
        ];
    }

    /** Drive backup, shaped for the owner form. @return array<string,mixed> */
    public static function gdriveForDisplay(): array
    {
        $g = self::gdrive();
        $ids = is_array($g['fileIds'] ?? null) ? $g['fileIds'] : [];
        // Backup bundles = tracked Drive files other than the (kept) signed index.
        $bundleCount = 0;
        foreach ($ids as $name => $_) { if ($name !== 'index.json') $bundleCount++; }
        return [
            'enabled'     => !empty($g['enabled']),
            'connection'  => trim((string) ($g['connection'] ?? '')),
            'indexUrl'    => trim((string) ($g['indexUrl'] ?? '')),
            'configured'  => self::hasGdrive(),
            'fileCount'   => count($ids),
            'bundleCount' => $bundleCount,
        ];
    }

    /**
     * Save owner-entered settings. A blank secret field means "keep the existing
     * one" — so the owner can edit the host without re-typing the password the
     * form never showed them.
     *
     * @param array<string,mixed> $in
     * @return array{ok:bool, errors:array<int,string>}
     */
    public static function save(array $in): array
    {
        $errors = [];
        $existing = self::raw();

        $protocol = strtolower(trim((string) ($in['protocol'] ?? 'ftp')));
        if (!in_array($protocol, ['ftp', 'ftps', 'sftp'], true)) $protocol = 'ftp';

        $host = trim((string) ($in['host'] ?? ''));
        if ($host === '') $errors[] = 'A host is required.';

        $port = (int) ($in['port'] ?? 0);
        if ($port < 0 || $port > 65535) { $errors[] = 'Port must be between 0 and 65535.'; $port = 0; }

        $remoteDir = trim((string) ($in['remoteDir'] ?? ''));
        if ($remoteDir === '') $errors[] = 'An upload directory is required.';

        $publicUrl = trim((string) ($in['publicUrl'] ?? ''));
        if ($publicUrl !== '' && !preg_match('#^https?://[^\s/]+#i', $publicUrl)) {
            $errors[] = 'The public download URL must start with http:// or https://';
        }
        if ($publicUrl === '') $errors[] = 'A public download URL is required (this is where clients fetch bundles).';

        $channel = strtolower(trim((string) ($in['channel'] ?? 'stable')));
        if (!in_array($channel, ['stable', 'beta'], true)) $channel = 'stable';

        if ($errors !== []) return ['ok' => false, 'errors' => $errors];

        // Blank secret = keep the stored one.
        $pass = (string) ($in['pass'] ?? '');
        if ($pass === '') $pass = (string) ($existing['pass'] ?? '');
        $keyPass = (string) ($in['privateKeyPass'] ?? '');
        if ($keyPass === '') $keyPass = (string) ($existing['privateKeyPass'] ?? '');

        // ── Secondary FTP (optional) — a blank host simply disables it. ──
        $sIn = is_array($in['secondary'] ?? null) ? $in['secondary'] : [];
        $sEx = is_array($existing['secondary'] ?? null) ? $existing['secondary'] : [];
        $secondary = null;
        $sHost = trim((string) ($sIn['host'] ?? ''));
        if ($sHost !== '') {
            $sProto = strtolower(trim((string) ($sIn['protocol'] ?? 'ftp')));
            if (!in_array($sProto, ['ftp', 'ftps', 'sftp'], true)) $sProto = 'ftp';
            $sPub = trim((string) ($sIn['publicUrl'] ?? ''));
            if ($sPub !== '' && !preg_match('#^https?://[^\s/]+#i', $sPub)) {
                $errors[] = 'The secondary public download URL must start with http:// or https://';
            }
            $sPass = (string) ($sIn['pass'] ?? '');
            if ($sPass === '') $sPass = (string) ($sEx['pass'] ?? '');
            $sKeyPass = (string) ($sIn['privateKeyPass'] ?? '');
            if ($sKeyPass === '') $sKeyPass = (string) ($sEx['privateKeyPass'] ?? '');
            $secondary = [
                'protocol'       => $sProto,
                'host'           => $sHost,
                'port'           => (int) ($sIn['port'] ?? 0),
                'user'           => (string) ($sIn['user'] ?? ''),
                'pass'           => $sPass,
                'remoteDir'      => trim((string) ($sIn['remoteDir'] ?? '')),
                'publicUrl'      => rtrim($sPub, '/'),
                'passive'        => (bool) ($sIn['passive'] ?? true),
                'privateKeyFile' => (string) ($sIn['privateKeyFile'] ?? ($sEx['privateKeyFile'] ?? '')),
                'privateKeyPass' => $sKeyPass,
            ];
        }

        // ── Google Drive backup (optional) — preserve the published index/file-ids. ──
        $gIn = is_array($in['gdrive'] ?? null) ? $in['gdrive'] : [];
        $gEx = is_array($existing['gdrive'] ?? null) ? $existing['gdrive'] : [];
        $gdrive = [
            'enabled'    => !empty($gIn['enabled']),
            'connection' => preg_replace('/[^A-Za-z0-9_-]/', '', (string) ($gIn['connection'] ?? ($gEx['connection'] ?? ''))),
            'indexUrl'   => (string) ($gEx['indexUrl'] ?? ''),   // set by the publisher, not the form
            'fileIds'    => is_array($gEx['fileIds'] ?? null) ? $gEx['fileIds'] : [],
        ];

        if ($errors !== []) return ['ok' => false, 'errors' => $errors];

        PlatformSettings::set(self::SETTING, ['value' => [
            'protocol'       => $protocol,
            'host'           => $host,
            'port'           => $port,
            'user'           => (string) ($in['user'] ?? ''),
            'pass'           => $pass,
            'remoteDir'      => $remoteDir,
            'publicUrl'      => rtrim($publicUrl, '/'),
            'channel'        => $channel,
            'passive'        => (bool) ($in['passive'] ?? true),
            'privateKeyFile' => (string) ($in['privateKeyFile'] ?? ($existing['privateKeyFile'] ?? '')),
            'privateKeyPass' => $keyPass,
            'secondary'      => $secondary,
            'gdrive'         => $gdrive,
        ]]);

        // Pin the index URL into source so future client bundles ship knowing
        // where to read their signed index (mirrors LicenseTrust's key pin).
        try { UpdateTrust::stamp(rtrim($publicUrl, '/') . '/index.json'); } catch (\Throwable $e) {}

        return ['ok' => true, 'errors' => []];
    }

    /**
     * Save ONLY the secondary FTP host, merging into the existing config so the
     * primary is untouched. A blank host disables the secondary. A blank password
     * keeps the stored one.
     *
     * @param array<string,mixed> $in
     * @return array{ok:bool, errors:array<int,string>}
     */
    public static function saveSecondary(array $in): array
    {
        $errors = [];
        $raw = self::raw();
        $sEx = is_array($raw['secondary'] ?? null) ? $raw['secondary'] : [];

        $host = trim((string) ($in['host'] ?? ''));
        if ($host === '') {
            // Blank host = remove the secondary entirely.
            $raw['secondary'] = null;
            PlatformSettings::set(self::SETTING, ['value' => $raw]);
            return ['ok' => true, 'errors' => []];
        }

        $protocol = strtolower(trim((string) ($in['protocol'] ?? 'ftp')));
        if (!in_array($protocol, ['ftp', 'ftps', 'sftp'], true)) $protocol = 'ftp';
        $port = (int) ($in['port'] ?? 0);
        if ($port < 0 || $port > 65535) { $errors[] = 'Port must be between 0 and 65535.'; $port = 0; }
        $remoteDir = trim((string) ($in['remoteDir'] ?? ''));
        if ($remoteDir === '') $errors[] = 'A secondary upload directory is required.';
        $publicUrl = trim((string) ($in['publicUrl'] ?? ''));
        if ($publicUrl === '') $errors[] = 'A secondary public download URL is required.';
        elseif (!preg_match('#^https?://[^\s/]+#i', $publicUrl)) $errors[] = 'The secondary public URL must start with http:// or https://';
        if ($errors !== []) return ['ok' => false, 'errors' => $errors];

        $pass = (string) ($in['pass'] ?? '');
        if ($pass === '') $pass = (string) ($sEx['pass'] ?? '');
        $keyPass = (string) ($in['privateKeyPass'] ?? '');
        if ($keyPass === '') $keyPass = (string) ($sEx['privateKeyPass'] ?? '');

        $raw['secondary'] = [
            'protocol'       => $protocol,
            'host'           => $host,
            'port'           => $port,
            'user'           => (string) ($in['user'] ?? ''),
            'pass'           => $pass,
            'remoteDir'      => $remoteDir,
            'publicUrl'      => rtrim($publicUrl, '/'),
            'passive'        => (bool) ($in['passive'] ?? true),
            'privateKeyFile' => (string) ($in['privateKeyFile'] ?? ($sEx['privateKeyFile'] ?? '')),
            'privateKeyPass' => $keyPass,
        ];
        PlatformSettings::set(self::SETTING, ['value' => $raw]);
        return ['ok' => true, 'errors' => []];
    }

    /**
     * Save ONLY the Google Drive backup toggle + connection, preserving the
     * publisher-recorded index URL and file-id map.
     *
     * @param array<string,mixed> $in
     * @return array{ok:bool, errors:array<int,string>}
     */
    public static function saveGdrive(array $in): array
    {
        $raw = self::raw();
        $gEx = is_array($raw['gdrive'] ?? null) ? $raw['gdrive'] : [];
        $enabled = !empty($in['enabled']);
        $conn = preg_replace('/[^A-Za-z0-9_-]/', '', (string) ($in['connection'] ?? ($gEx['connection'] ?? '')));
        if ($enabled && $conn === '') {
            return ['ok' => false, 'errors' => ['Pick a connected Google Drive storage connection first.']];
        }
        $raw['gdrive'] = [
            'enabled'    => $enabled,
            'connection' => $conn,
            'indexUrl'   => (string) ($gEx['indexUrl'] ?? ''),
            'fileIds'    => is_array($gEx['fileIds'] ?? null) ? $gEx['fileIds'] : [],
        ];
        PlatformSettings::set(self::SETTING, ['value' => $raw]);
        return ['ok' => true, 'errors' => []];
    }
}
