<?php
declare(strict_types=1);

/**
 * On-demand remote access control plane (heartbeat type: ra).
 * v1: WireGuard + reverse SSH (RSSH) opt-in from device (lab / alpha).
 */
final class RemoteAccessRepository {
    public const OPT_NONE = 'none';
    public const OPT_WIREGUARD = 'wireguard';
    public const OPT_RSSH = 'rssh';

    public const STATUS_PENDING = 'pending';
    public const STATUS_ACTIVE = 'active';
    public const STATUS_INACTIVE = 'inactive';
    public const STATUS_CLOSED = 'closed';
    public const STATUS_CLOSED_TIMEOUT = 'closed_timeout';

    public static function ensureSchema(): void {
        $pdo = Database::pdo();
        if (!Database::tableExists($pdo, 'remote_access_devices')) {
            self::createTables($pdo);
            return;
        }
        self::ensureSessionColumns($pdo);
        self::ensureRsshColumns($pdo);
    }

    /** @param list<string> $where @param list<mixed> $params */
    private static function appendCompanyScopeSql(string $column, array &$where, array &$params): void {
        Rbac::appendCompanyScope($column, $where, $params);
    }

    public static function canAccessDevice(string $deviceId): bool {
        $device = self::findDevice($deviceId);
        if ($device === null) {
            return Rbac::isGlobalAdmin();
        }

        return Rbac::canAccessCompany((int) ($device['company_id'] ?? 0));
    }

    public static function canOperatorCloseSession(string $sessionId, int $operatorUserId): bool {
        $session = self::findSession($sessionId);
        if ($session === null) {
            return false;
        }
        $companyId = (int) ($session['company_id'] ?? 0);
        if (!Rbac::canAccessCompany($companyId)) {
            return false;
        }
        if (Rbac::isGlobalAdmin() || Rbac::can('remote_access_admin', $companyId)) {
            return true;
        }

        return $operatorUserId > 0
            && (int) ($session['operator_user_id'] ?? 0) === $operatorUserId
            && Rbac::can('remote_access_operate', $companyId);
    }

    /** @return array<string, mixed>|null */
    public static function findSession(string $sessionId): ?array {
        self::ensureSchema();
        $st = Database::pdo()->prepare('SELECT * FROM remote_access_sessions WHERE session_id = ? LIMIT 1');
        $st->execute([$sessionId]);
        $row = $st->fetch(PDO::FETCH_ASSOC);

        return $row ?: null;
    }

    private static function ensureRsshColumns(PDO $pdo): void {
        if (!Database::tableExists($pdo, 'remote_access_sessions')) {
            return;
        }
        $cols = [
            'rssh_bastion_host' => Database::isMysql() ? 'VARCHAR(255) NULL' : 'TEXT NULL',
            'rssh_bastion_port' => Database::isMysql() ? 'INT UNSIGNED NULL' : 'INTEGER NULL',
            'rssh_username' => Database::isMysql() ? 'VARCHAR(64) NULL' : 'TEXT NULL',
            'rssh_secret' => Database::isMysql() ? 'VARCHAR(128) NULL' : 'TEXT NULL',
            'rssh_remote_port' => Database::isMysql() ? 'INT UNSIGNED NULL' : 'INTEGER NULL',
            'rssh_local_port' => Database::isMysql() ? 'INT UNSIGNED NULL' : 'INTEGER NULL',
        ];
        foreach ($cols as $name => $ddl) {
            try {
                $pdo->exec('ALTER TABLE remote_access_sessions ADD COLUMN ' . $name . ' ' . $ddl);
            } catch (PDOException $e) {
                $msg = strtolower($e->getMessage());
                if (!str_contains($msg, 'duplicate') && !str_contains($msg, 'already exists')) {
                    throw $e;
                }
            }
        }
    }

    private static function ensureSessionColumns(PDO $pdo): void {
        if (!Database::tableExists($pdo, 'remote_access_sessions')) {
            return;
        }
        $col = Database::isMysql() ? 'VARCHAR(64) NULL' : 'TEXT NULL';
        try {
            $pdo->exec('ALTER TABLE remote_access_sessions ADD COLUMN wg_client_public_key ' . $col);
        } catch (PDOException $e) {
            $msg = strtolower($e->getMessage());
            if (!str_contains($msg, 'duplicate') && !str_contains($msg, 'already exists')) {
                throw $e;
            }
        }
    }

    private static function createTables(PDO $pdo): void {
        $path = Database::isMysql()
            ? __DIR__ . '/../sql/migrations/007_remote_access.sql'
            : __DIR__ . '/../sql/migrations/007_remote_access.sqlite.sql';
        if (!is_readable($path)) {
            throw new RuntimeException('remote access migration missing: ' . $path);
        }
        $sql = (string) file_get_contents($path);
        foreach (array_filter(array_map('trim', explode(';', $sql))) as $stmt) {
            if ($stmt !== '') {
                $pdo->exec($stmt);
            }
        }
    }

    /** @return array<string, mixed> heartbeat.ra response payload */
    public static function handleDeviceHeartbeat(array $heartbeat, string $clientIp): array {
        self::ensureSchema();
        $deviceId = trim((string) ($heartbeat['device_id'] ?? ''));
        if ($deviceId === '') {
            return self::raEnvelope('deny', ['reason' => 'missing_device_id']);
        }

        $status = strtolower(trim((string) ($heartbeat['status'] ?? 'enable')));
        $random = trim((string) ($heartbeat['random'] ?? ''));
        $appVersion = trim((string) ($heartbeat['app_version'] ?? ''));
        $capabilities = $heartbeat['capabilities'] ?? [];
        if (!is_array($capabilities)) {
            $capabilities = [];
        }
        $tunnelMode = self::inferTunnelMode($capabilities, $heartbeat);

        if ($status === 'disable') {
            return self::handleDisable($deviceId, $random, $clientIp, $appVersion);
        }

        $existing = self::findDevice($deviceId);
        $pendingSession = null;
        if ($existing !== null && self::isRateLimited($existing)) {
            $pendingSession = self::findConnectableSession($deviceId, $random);
            if ($pendingSession === null) {
                return self::raEnvelope('wait', ['reason' => 'rate_limited']);
            }
        }

        self::upsertDevicePoll($deviceId, $random, $appVersion, $tunnelMode, $clientIp);

        $device = self::findDevice($deviceId);
        if (!$device || !(int) ($device['whitelisted'] ?? 0)) {
            self::logEvent($deviceId, null, null, 'poll_wait', 'not_whitelisted', [
                'opt_in' => $tunnelMode,
            ], $clientIp);
            return self::raEnvelope('wait', ['reason' => 'not_whitelisted']);
        }

        if ($tunnelMode === self::OPT_NONE) {
            return self::raEnvelope('wait', ['reason' => 'opt_in_none']);
        }

        if ($tunnelMode === self::OPT_RSSH) {
            $session = $pendingSession ?? self::findConnectableSession($deviceId, $random);
            if ($session === null) {
                self::logEvent($deviceId, null, null, 'poll_wait', 'no_pending_session', null, $clientIp);
                return self::raEnvelope('wait', ['reason' => 'no_pending_session']);
            }
            self::touchSessionActivity((string) ($session['session_id'] ?? ''));
            return self::buildConnectResponse($session, $deviceId, $clientIp);
        }

        $session = $pendingSession ?? self::findConnectableSession($deviceId, $random);
        if ($session === null) {
            self::logEvent($deviceId, null, null, 'poll_wait', 'no_pending_session', null, $clientIp);
            return self::raEnvelope('wait', ['reason' => 'no_pending_session']);
        }

        self::touchSessionActivity((string) ($session['session_id'] ?? ''));

        return self::buildConnectResponse($session, $deviceId, $clientIp);
    }

    /** @param array<string, mixed> $device */
    private static function isRateLimited(array $device): bool {
        $min = max(15, (int) cfg('remote_access.min_poll_interval_s', 45));
        $last = (string) ($device['last_seen_at'] ?? '');
        if ($last === '') {
            return false;
        }
        $ts = strtotime($last);
        if ($ts === false) {
            return false;
        }
        return (time() - $ts) < $min;
    }

    private static function touchSessionActivity(string $sessionId): void {
        if ($sessionId === '') {
            return;
        }
        Database::pdo()->prepare(
            'UPDATE remote_access_sessions SET last_activity_at = ? WHERE session_id = ?'
        )->execute([self::nowSql(), $sessionId]);
    }

    /** @param list<string> $capabilities */
    private static function inferTunnelMode(array $capabilities, array $heartbeat): string {
        foreach ($capabilities as $cap) {
            $c = strtolower(trim((string) $cap));
            if ($c === 'wireguard' || $c === 'wg') {
                return self::OPT_WIREGUARD;
            }
            if ($c === 'ssh_reverse' || $c === 'rssh') {
                return self::OPT_RSSH;
            }
        }
        $mode = strtolower(trim((string) ($heartbeat['tunnel_mode'] ?? '')));
        if (in_array($mode, [self::OPT_WIREGUARD, self::OPT_RSSH, self::OPT_NONE], true)) {
            return $mode;
        }
        return self::OPT_WIREGUARD;
    }

    private static function handleDisable(string $deviceId, string $random, string $clientIp, string $appVersion): array {
        $pdo = Database::pdo();
        $now = self::nowSql();
        $pdo->prepare(
            'UPDATE remote_access_devices SET opt_in_mode = ?, last_random = ?, app_version = ?, last_seen_at = ?, updated_at = ? WHERE device_id = ?'
        )->execute([self::OPT_NONE, $random, $appVersion, $now, $now, $deviceId]);

        self::closeActiveSessionsForDevice($deviceId, 'user_disabled');
        self::logEvent($deviceId, null, null, 'user_disable', 'device_opt_out', null, $clientIp);

        return self::raEnvelope('disabled', ['reason' => 'user_opt_out']);
    }

    private static function upsertDevicePoll(
        string $deviceId,
        string $random,
        string $appVersion,
        string $tunnelMode,
        string $clientIp
    ): void {
        $pdo = Database::pdo();
        $now = self::nowSql();
        $existing = self::findDevice($deviceId);
        if ($existing) {
            $pdo->prepare(
                'UPDATE remote_access_devices SET opt_in_mode = ?, last_random = ?, app_version = ?, last_seen_at = ?, updated_at = ? WHERE device_id = ?'
            )->execute([$tunnelMode, $random, $appVersion, $now, $now, $deviceId]);
            return;
        }
        $pdo->prepare(
            'INSERT INTO remote_access_devices (device_id, company_id, whitelisted, opt_in_mode, last_random, app_version, last_seen_at, created_at, updated_at)
             VALUES (?, ?, 0, ?, ?, ?, ?, ?, ?)'
        )->execute([
            $deviceId,
            Rbac::defaultCompanyId(),
            $tunnelMode,
            $random,
            $appVersion,
            $now,
            $now,
            $now,
        ]);
        self::logEvent($deviceId, null, null, 'device_seen', 'first_poll', ['opt_in' => $tunnelMode], $clientIp);
    }

    /** @return array<string, mixed>|null */
    private static function findDevice(string $deviceId): ?array {
        $st = Database::pdo()->prepare('SELECT * FROM remote_access_devices WHERE device_id = ? LIMIT 1');
        $st->execute([$deviceId]);
        $row = $st->fetch(PDO::FETCH_ASSOC);
        return is_array($row) ? $row : null;
    }

    /** @return array<string, mixed>|null */
    private static function findConnectableSession(string $deviceId, string $random): ?array {
        $pdo = Database::pdo();
        $st = $pdo->prepare(
            "SELECT * FROM remote_access_sessions
             WHERE device_id = ? AND status IN ('pending','active')
             ORDER BY id DESC LIMIT 5"
        );
        $st->execute([$deviceId]);
        $rows = $st->fetchAll(PDO::FETCH_ASSOC);
        if (!is_array($rows)) {
            return null;
        }
        foreach ($rows as $row) {
            if (!is_array($row)) {
                continue;
            }
            $exp = (int) ($row['expires_at'] ?? 0);
            if ($exp > 0 && $exp < time()) {
                self::closeSession((string) $row['session_id'], self::STATUS_CLOSED_TIMEOUT, 'expired');
                continue;
            }
            return $row;
        }
        return null;
    }

    /** @param array<string, mixed> $session */
    private static function buildConnectResponse(array $session, string $deviceId, string $clientIp): array {
        $tunnel = (string) ($session['tunnel'] ?? 'wireguard');
        if ($tunnel === 'ssh_reverse') {
            return self::buildRsshConnectResponse($session, $deviceId, $clientIp);
        }
        return self::buildWireGuardConnectResponse($session, $deviceId, $clientIp);
    }

    /** @param array<string, mixed> $session */
    private static function buildWireGuardConnectResponse(array $session, string $deviceId, string $clientIp): array {
        $sessionId = (string) ($session['session_id'] ?? '');
        $tunnel = (string) ($session['tunnel'] ?? 'wireguard');
        if ($tunnel !== 'wireguard') {
            return self::raEnvelope('deny', ['reason' => 'tunnel_not_supported', 'tunnel' => $tunnel]);
        }

        $keys = [];
        try {
            $keys = self::ensureSessionWireGuardKeys($sessionId, $session);
        } catch (Throwable $e) {
            error_log('RemoteAccess connect keys: ' . $e->getMessage());
            return self::raEnvelope('deny', ['reason' => 'key_generation_failed']);
        }
        if (str_starts_with($keys['client_public'] ?? '', 'dev-placeholder-pub-')
            && (bool) cfg('remote_access.require_wg_tools', false)) {
            return self::raEnvelope('deny', ['reason' => 'wg_tools_required']);
        }
        $endpoint = (string) ($session['endpoint'] ?? WireGuardAddressPool::defaultEndpoint());
        $expiresAt = (int) ($session['expires_at'] ?? (time() + 3600));

        self::markSessionActive($sessionId);
        self::logEvent($deviceId, $sessionId, null, 'tunnel_connect', 'wireguard', null, $clientIp);

        return self::raEnvelope('connect', [
            'session_id' => $sessionId,
            'endpoint' => $endpoint,
            'tunnel' => 'wireguard',
            'credentials' => [
                'mode' => 'ephemeral_pubkey',
                'interface_private_key' => $keys['client_private'],
                'interface_address' => $keys['client_address'],
                'peer_public_key' => $keys['server_public'],
                'peer_endpoint' => $endpoint,
                'peer_allowed_ips' => $keys['allowed_ips'],
            ],
            'expires_at' => $expiresAt,
        ]);
    }

    /** @param array<string, mixed> $session */
    private static function buildRsshConnectResponse(array $session, string $deviceId, string $clientIp): array {
        $sessionId = (string) ($session['session_id'] ?? '');
        $creds = self::ensureSessionRsshCredentials($sessionId, $session);
        $expiresAt = (int) ($session['expires_at'] ?? (time() + 3600));
        $endpoint = RsshSessionProvisioner::endpointFromSession($session);

        self::markSessionActive($sessionId);
        self::logEvent($deviceId, $sessionId, null, 'tunnel_connect', 'ssh_reverse', null, $clientIp);

        return self::raEnvelope('connect', [
            'session_id' => $sessionId,
            'endpoint' => $endpoint,
            'tunnel' => 'ssh_reverse',
            'credentials' => [
                'mode' => 'password',
                'bastion_host' => $creds['bastion_host'],
                'bastion_port' => $creds['bastion_port'],
                'username' => $creds['username'],
                'password' => $creds['password'],
                'remote_bind_port' => $creds['remote_bind_port'],
                'local_forward_host' => '127.0.0.1',
                'local_forward_port' => $creds['local_forward_port'],
            ],
            'expires_at' => $expiresAt,
        ]);
    }

    /** @param array<string, mixed> $session @return array{bastion_host:string,bastion_port:int,username:string,password:string,remote_bind_port:int,local_forward_port:int} */
    private static function ensureSessionRsshCredentials(string $sessionId, array $session): array {
        $user = trim((string) ($session['rssh_username'] ?? ''));
        $secret = trim((string) ($session['rssh_secret'] ?? ''));
        $remote = (int) ($session['rssh_remote_port'] ?? 0);
        $local = (int) ($session['rssh_local_port'] ?? 0);
        $host = trim((string) ($session['rssh_bastion_host'] ?? ''));
        $port = (int) ($session['rssh_bastion_port'] ?? 0);
        if ($user !== '' && $secret !== '' && $remote > 0) {
            return [
                'bastion_host' => $host !== '' ? $host : (string) cfg('remote_access.rssh.bastion_host', 'ra.apps.f0xx.org'),
                'bastion_port' => $port > 0 ? $port : (int) cfg('remote_access.rssh.bastion_port', 443),
                'username' => $user,
                'password' => $secret,
                'remote_bind_port' => $remote,
                'local_forward_port' => $local > 0 ? $local : (int) cfg('remote_access.rssh.local_forward_port', 8022),
            ];
        }
        $alloc = RsshSessionProvisioner::allocate($sessionId);
        Database::pdo()->prepare(
            'UPDATE remote_access_sessions SET rssh_bastion_host = ?, rssh_bastion_port = ?, rssh_username = ?, rssh_secret = ?, rssh_remote_port = ?, rssh_local_port = ? WHERE session_id = ?'
        )->execute([
            $alloc['bastion_host'],
            $alloc['bastion_port'],
            $alloc['username'],
            $alloc['password'],
            $alloc['remote_bind_port'],
            $alloc['local_forward_port'],
            $sessionId,
        ]);
        return $alloc;
    }

    /** @param array<string, mixed> $session @return array{client_private:string,client_address:string,server_public:string,allowed_ips:string,client_public:string} */
    private static function ensureSessionWireGuardKeys(string $sessionId, array $session): array {
        $priv = trim((string) ($session['wg_client_private_key'] ?? ''));
        $pub = trim((string) ($session['wg_client_public_key'] ?? ''));
        $addr = trim((string) ($session['wg_client_address'] ?? ''));
        $serverPub = trim((string) ($session['wg_server_public_key'] ?? ''));
        $allowed = trim((string) ($session['wg_peer_allowed_ips'] ?? ''));
        if ($priv !== '' && $addr !== '' && $serverPub !== '') {
            if ($pub === '') {
                $pub = WireGuardPeerProvisioner::publicKeyFromPrivate($priv);
            }
            return [
                'client_private' => $priv,
                'client_public' => $pub,
                'client_address' => $addr,
                'server_public' => $serverPub,
                'allowed_ips' => $allowed !== '' ? $allowed : WireGuardAddressPool::serverAllowedIps(),
            ];
        }
        $pair = self::generateWireGuardKeyPair();
        $priv = $pair['private'];
        $pub = $pair['public'];
        if ($pub === '' || str_starts_with($pub, 'dev-placeholder-pub-')) {
            $pub = WireGuardPeerProvisioner::publicKeyFromPrivate($priv);
        }
        $clientIp = WireGuardAddressPool::allocateClientAddress($sessionId);
        $serverPub = (string) cfg('remote_access.wg_server_public_key', '');
        if ($serverPub === '') {
            $serverPair = self::generateWireGuardKeyPair();
            $serverPub = $serverPair['public'];
        }
        $allowed = WireGuardAddressPool::serverAllowedIps();
        Database::pdo()->prepare(
            'UPDATE remote_access_sessions SET wg_client_private_key = ?, wg_client_public_key = ?, wg_client_address = ?, wg_server_public_key = ?, wg_peer_allowed_ips = ? WHERE session_id = ?'
        )->execute([$priv, $pub, $clientIp, $serverPub, $allowed, $sessionId]);

        if (!WireGuardPeerProvisioner::addClientPeer($pub, $clientIp)) {
            error_log('RemoteAccess: wg peer add failed for session ' . $sessionId);
        }

        return [
            'client_private' => $priv,
            'client_public' => $pub,
            'client_address' => $clientIp,
            'server_public' => $serverPub,
            'allowed_ips' => $allowed,
        ];
    }

    /** @return array{private:string,public:string} */
    public static function generateWireGuardKeyPair(): array {
        $priv = trim((string) @shell_exec('wg genkey 2>/dev/null') ?: '');
        if ($priv !== '') {
            $pub = trim((string) @shell_exec('echo ' . escapeshellarg($priv) . ' | wg pubkey 2>/dev/null') ?: '');
            if ($pub !== '') {
                return ['private' => $priv, 'public' => $pub];
            }
        }
        if ((bool) cfg('remote_access.require_wg_tools', false)) {
            throw new RuntimeException('wireguard-tools unavailable (wg genkey)');
        }
        $raw = random_bytes(32);
        $priv = rtrim(strtr(base64_encode($raw), '+/', '-_'), '=');
        return ['private' => $priv, 'public' => 'dev-placeholder-pub-' . substr(hash('sha256', $priv), 0, 43)];
    }

    private static function markSessionActive(string $sessionId): void {
        $now = self::nowSql();
        Database::pdo()->prepare(
            "UPDATE remote_access_sessions SET status = 'active', last_activity_at = ? WHERE session_id = ?"
        )->execute([$now, $sessionId]);
    }

    public static function closeActiveSessionsForDevice(string $deviceId, string $reason): void {
        $st = Database::pdo()->prepare(
            "SELECT session_id FROM remote_access_sessions WHERE device_id = ? AND status IN ('pending','active')"
        );
        $st->execute([$deviceId]);
        foreach ($st->fetchAll(PDO::FETCH_COLUMN) as $sid) {
            self::closeSession((string) $sid, self::STATUS_CLOSED, $reason);
        }
    }

    public static function closeSession(string $sessionId, string $status, string $reason): void {
        $st = Database::pdo()->prepare(
            'SELECT tunnel, wg_client_public_key FROM remote_access_sessions WHERE session_id = ? LIMIT 1'
        );
        $st->execute([$sessionId]);
        $row = $st->fetch(PDO::FETCH_ASSOC);
        if (is_array($row) && ($row['tunnel'] ?? 'wireguard') === 'wireguard') {
            $pub = $row['wg_client_public_key'] ?? '';
            if (is_string($pub) && $pub !== '') {
                WireGuardPeerProvisioner::removeClientPeer($pub);
            }
        }
        $now = self::nowSql();
        Database::pdo()->prepare(
            'UPDATE remote_access_sessions SET status = ?, close_reason = ?, closed_at = ?, last_activity_at = ? WHERE session_id = ?'
        )->execute([$status, $reason, $now, $now, $sessionId]);
    }

    /** @return array{ok:bool,session_id?:string} */
    public static function openSession(string $deviceId, int $operatorUserId, ?int $companyId = null): array {
        self::ensureSchema();
        $device = self::findDevice($deviceId);
        if (!$device || !(int) ($device['whitelisted'] ?? 0)) {
            return ['ok' => false, 'error' => 'device_not_whitelisted'];
        }
        $deviceCompanyId = (int) ($device['company_id'] ?? 0);
        if (!Rbac::canAccessCompany($deviceCompanyId) || !Rbac::can('remote_access_operate', $deviceCompanyId)) {
            return ['ok' => false, 'error' => 'forbidden'];
        }
        if ((string) ($device['opt_in_mode'] ?? self::OPT_NONE) === self::OPT_NONE) {
            return ['ok' => false, 'error' => 'device_not_opted_in'];
        }
        $optIn = (string) ($device['opt_in_mode'] ?? self::OPT_NONE);
        if (!in_array($optIn, [self::OPT_WIREGUARD, self::OPT_RSSH], true)) {
            return ['ok' => false, 'error' => 'device_not_opted_in'];
        }
        self::closeActiveSessionsForDevice($deviceId, 'superseded');
        $sessionId = self::uuid4();
        $companyId = $companyId ?? (int) ($device['company_id'] ?? Rbac::defaultCompanyId());
        $tunnel = $optIn === self::OPT_RSSH ? 'ssh_reverse' : 'wireguard';
        if ($tunnel === 'ssh_reverse') {
            $host = (string) cfg('remote_access.rssh.bastion_host', 'ra.apps.f0xx.org');
            $port = (int) cfg('remote_access.rssh.bastion_port', 443);
            $endpoint = $port === 443 ? $host : $host . ':' . $port;
        } else {
            $endpoint = WireGuardAddressPool::defaultEndpoint();
        }
        $expiresAt = time() + (int) cfg('remote_access.session_ttl_s', 3600);
        $now = self::nowSql();
        Database::pdo()->prepare(
            'INSERT INTO remote_access_sessions (session_id, device_id, company_id, operator_user_id, status, tunnel, endpoint, expires_at, last_activity_at, created_at)
             VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)'
        )->execute([
            $sessionId,
            $deviceId,
            $companyId,
            $operatorUserId,
            self::STATUS_PENDING,
            $tunnel,
            $endpoint,
            $expiresAt,
            $now,
            $now,
        ]);
        self::logEvent($deviceId, $sessionId, $operatorUserId, 'session_open', 'operator_requested', null, '');
        return ['ok' => true, 'session_id' => $sessionId];
    }

    /** @return list<array<string, mixed>> */
    public static function listDevices(?int $companyId = null): array {
        self::ensureSchema();
        $sql = 'SELECT * FROM remote_access_devices';
        $params = [];
        if (!Rbac::isGlobalAdmin()) {
            $allowed = Rbac::allowedCompanyIds();
            if ($allowed === []) {
                return [];
            }
            if ($allowed !== null) {
                $placeholders = implode(',', array_fill(0, count($allowed), '?'));
                $sql .= ' WHERE company_id IN (' . $placeholders . ')';
                $params = $allowed;
            }
        } elseif ($companyId !== null) {
            $sql .= ' WHERE company_id = ?';
            $params[] = $companyId;
        }
        $sql .= ' ORDER BY last_seen_at DESC, device_id ASC';
        $st = Database::pdo()->prepare($sql);
        $st->execute($params);
        return $st->fetchAll(PDO::FETCH_ASSOC) ?: [];
    }

    /** @return list<array<string, mixed>> */
    public static function listSessions(string $statusFilter = ''): array {
        self::ensureSchema();
        $sql = 'SELECT s.*, d.opt_in_mode, d.app_version AS device_app_version FROM remote_access_sessions s
                LEFT JOIN remote_access_devices d ON d.device_id = s.device_id';
        $where = [];
        $params = [];
        if ($statusFilter !== '') {
            $where[] = 's.status = ?';
            $params[] = $statusFilter;
        }
        self::appendCompanyScopeSql('s.company_id', $where, $params);
        if ($where !== []) {
            $sql .= ' WHERE ' . implode(' AND ', $where);
        }
        $sql .= ' ORDER BY s.id DESC LIMIT 200';
        $st = Database::pdo()->prepare($sql);
        $st->execute($params);
        return $st->fetchAll(PDO::FETCH_ASSOC) ?: [];
    }

    /** @return list<array<string, mixed>> */
    public static function listEvents(int $limit = 100): array {
        self::ensureSchema();
        $sql = 'SELECT e.* FROM remote_access_events e';
        $where = [];
        $params = [];
        if (!Rbac::isGlobalAdmin()) {
            $allowed = Rbac::allowedCompanyIds();
            if ($allowed === []) {
                return [];
            }
            if ($allowed !== null) {
                $sql .= ' INNER JOIN remote_access_devices d ON d.device_id = e.device_id';
                self::appendCompanyScopeSql('d.company_id', $where, $params);
            }
        }
        if ($where !== []) {
            $sql .= ' WHERE ' . implode(' AND ', $where);
        }
        $sql .= ' ORDER BY e.id DESC LIMIT ?';
        $params[] = max(1, min(500, $limit));
        $st = Database::pdo()->prepare($sql);
        foreach ($params as $i => $v) {
            $st->bindValue($i + 1, $v, is_int($v) ? PDO::PARAM_INT : PDO::PARAM_STR);
        }
        $st->execute();
        return $st->fetchAll(PDO::FETCH_ASSOC) ?: [];
    }

    public static function setDeviceWhitelist(string $deviceId, bool $whitelisted, ?string $notes = null): bool {
        self::ensureSchema();
        $device = self::findDevice($deviceId);
        if (!$device) {
            $companyId = Rbac::activeCompanyId() ?? Rbac::defaultCompanyId();
            if (!Rbac::can('remote_access_admin', $companyId)) {
                return false;
            }
            Database::pdo()->prepare(
                'INSERT INTO remote_access_devices (device_id, company_id, whitelisted, notes, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)'
            )->execute([
                $deviceId,
                $companyId,
                $whitelisted ? 1 : 0,
                $notes,
                self::nowSql(),
                self::nowSql(),
            ]);
            return true;
        }
        $companyId = (int) ($device['company_id'] ?? 0);
        if (!Rbac::canAccessCompany($companyId) || !Rbac::can('remote_access_admin', $companyId)) {
            return false;
        }
        Database::pdo()->prepare(
            'UPDATE remote_access_devices SET whitelisted = ?, notes = COALESCE(?, notes), updated_at = ? WHERE device_id = ?'
        )->execute([$whitelisted ? 1 : 0, $notes, self::nowSql(), $deviceId]);
        return true;
    }

    public static function purgeStale(int $maxAgeHours = 24): int {
        self::ensureSchema();
        $cutoff = time() - max(1, $maxAgeHours) * 3600;
        $pdo = Database::pdo();
        if (Database::isMysql()) {
            $st = $pdo->prepare(
                "SELECT session_id FROM remote_access_sessions WHERE status IN ('inactive','closed','closed_timeout','pending','active')
                 AND ((expires_at IS NOT NULL AND expires_at < ?) OR (last_activity_at IS NOT NULL AND UNIX_TIMESTAMP(last_activity_at) < ?))"
            );
            $st->execute([time(), $cutoff]);
        } else {
            $cutoffIso = gmdate('Y-m-d H:i:s', $cutoff);
            $st = $pdo->prepare(
                "SELECT session_id FROM remote_access_sessions WHERE status IN ('inactive','closed','closed_timeout','pending','active')
                 AND ((expires_at IS NOT NULL AND expires_at < ?) OR (last_activity_at IS NOT NULL AND last_activity_at < ?))"
            );
            $st->execute([time(), $cutoffIso]);
        }
        $n = 0;
        foreach ($st->fetchAll(PDO::FETCH_COLUMN) as $sid) {
            self::closeSession((string) $sid, self::STATUS_CLOSED_TIMEOUT, 'purge_stale');
            $n++;
        }
        return $n;
    }

    public static function logEvent(
        string $deviceId,
        ?string $sessionId,
        ?int $actorUserId,
        string $action,
        ?string $reason,
        ?array $meta,
        string $clientIp
    ): void {
        self::ensureSchema();
        Database::pdo()->prepare(
            'INSERT INTO remote_access_events (session_id, device_id, actor_user_id, action, reason, meta_json, client_ip, created_at)
             VALUES (?, ?, ?, ?, ?, ?, ?, ?)'
        )->execute([
            $sessionId,
            $deviceId,
            $actorUserId,
            $action,
            $reason,
            $meta !== null ? json_encode($meta, JSON_UNESCAPED_UNICODE) : null,
            $clientIp !== '' ? $clientIp : null,
            self::nowSql(),
        ]);
    }

    /** @param array<string, mixed> $extra */
    private static function raEnvelope(string $action, array $extra = []): array {
        return array_merge([
            'type' => 'ra',
            'action' => $action,
            'server_date' => time(),
        ], $extra);
    }

    private static function nowSql(): string {
        return Database::isMysql() ? date('Y-m-d H:i:s') : gmdate('Y-m-d H:i:s');
    }

    /** @return list<array<string, mixed>> */
    public static function listDevicesForDashboard(string $issuesBase, string $projectBase): array {
        $devices = self::listDevices();
        $out = [];
        foreach ($devices as $row) {
            $out[] = self::enrichDeviceRow($row, $issuesBase, $projectBase);
        }
        return $out;
    }

    /** @param array<string, mixed> $row */
    private static function enrichDeviceRow(array $row, string $issuesBase, string $projectBase): array {
        $deviceId = (string) ($row['device_id'] ?? '');
        $companyId = (int) ($row['company_id'] ?? 1);
        $ctx = self::lookupDeviceContext($deviceId, $companyId);
        $row['needs_whitelist'] = self::deviceNeedsWhitelist($row);
        $row['status_label'] = self::deviceStatusLabel($row);
        $row['device_display'] = $ctx['device_display'] ?? '';
        $row['manufacturer'] = $ctx['manufacturer'] ?? '';
        $row['model'] = $ctx['model'] ?? '';
        $row['os_release'] = $ctx['os_release'] ?? '';
        $row['sdk_int'] = $ctx['sdk_int'] ?? null;
        $row['issue_count'] = $ctx['issue_count'] ?? 0;
        $row['graph_session_count'] = $ctx['graph_session_count'] ?? 0;
        $row['latest_issue_id'] = $ctx['latest_issue_id'] ?? null;
        $row['links'] = self::deviceLinks($deviceId, $issuesBase, $projectBase, $ctx);
        if (($row['app_version'] ?? '') === '' && ($ctx['app_version'] ?? '') !== '') {
            $row['app_version'] = $ctx['app_version'];
        }
        return $row;
    }

    /** @param array<string, mixed> $row */
    private static function deviceNeedsWhitelist(array $row): bool {
        if ((int) ($row['whitelisted'] ?? 0) === 1) {
            return false;
        }
        $mode = (string) ($row['opt_in_mode'] ?? 'none');
        if ($mode === self::OPT_NONE) {
            return false;
        }
        return self::isRecentTimestamp($row['last_seen_at'] ?? null, 7 * 86400);
    }

    /** @param array<string, mixed> $row */
    private static function deviceStatusLabel(array $row): string {
        if ((int) ($row['whitelisted'] ?? 0) === 1) {
            return 'whitelisted';
        }
        if (self::deviceNeedsWhitelist($row)) {
            return 'needs_whitelist';
        }
        $mode = (string) ($row['opt_in_mode'] ?? 'none');
        if ($mode === self::OPT_NONE) {
            return 'not_opted_in';
        }
        if (!self::isRecentTimestamp($row['last_seen_at'] ?? null, 7 * 86400)) {
            return 'stale';
        }
        return 'polling';
    }

    private static function isRecentTimestamp(?string $ts, int $maxAgeSec): bool {
        if ($ts === null || trim($ts) === '') {
            return false;
        }
        $t = strtotime($ts);
        if ($t === false) {
            return false;
        }
        return (time() - $t) <= $maxAgeSec;
    }

    /** @return array<string, mixed> */
    private static function lookupDeviceContext(string $deviceId, int $companyId): array {
        if ($deviceId === '') {
            return [];
        }
        $pdo = Database::pdo();
        $like = '%' . $deviceId . '%';
        $issueCount = 0;
        $latestIssueId = null;
        $deviceDisplay = '';
        $manufacturer = '';
        $model = '';
        $osRelease = '';
        $sdkInt = null;
        $appVersion = '';

        if (Database::tableExists($pdo, 'reports')) {
            $cntSt = $pdo->prepare(
                'SELECT COUNT(*) FROM reports r WHERE r.company_id = ? AND r.payload_json LIKE ?'
            );
            $cntSt->execute([$companyId, $like]);
            $issueCount = (int) $cntSt->fetchColumn();

            $latestSt = $pdo->prepare(
                'SELECT r.id, r.device_model, r.app_version, r.payload_json
                 FROM reports r
                 WHERE r.company_id = ? AND r.payload_json LIKE ?
                 ORDER BY r.received_at_ms DESC LIMIT 1'
            );
            $latestSt->execute([$companyId, $like]);
            $latest = $latestSt->fetch(PDO::FETCH_ASSOC);
            if (is_array($latest)) {
                $latestIssueId = (int) ($latest['id'] ?? 0) ?: null;
                $appVersion = (string) ($latest['app_version'] ?? '');
                $payload = json_decode((string) ($latest['payload_json'] ?? '{}'), true);
                if (is_array($payload)) {
                    $device = is_array($payload['device'] ?? null) ? $payload['device'] : [];
                    $manufacturer = trim((string) ($device['manufacturer'] ?? ''));
                    $model = trim((string) ($device['model'] ?? $latest['device_model'] ?? ''));
                    $osRelease = trim((string) ($device['release'] ?? ''));
                    $sdkInt = isset($device['sdk_int']) ? (int) $device['sdk_int'] : null;
                    $deviceDisplay = trim($manufacturer . ' ' . $model);
                    if ($appVersion === '' && is_array($payload['app'] ?? null)) {
                        $appVersion = trim((string) ($payload['app']['version_name'] ?? ''));
                    }
                }
            }
        }

        $graphCount = 0;
        if (Database::tableExists($pdo, 'graph_sessions')) {
            $gSt = $pdo->prepare('SELECT COUNT(*) FROM graph_sessions WHERE device_id = ?');
            $gSt->execute([$deviceId]);
            $graphCount = (int) $gSt->fetchColumn();
        }

        return [
            'issue_count' => $issueCount,
            'latest_issue_id' => $latestIssueId,
            'graph_session_count' => $graphCount,
            'device_display' => $deviceDisplay,
            'manufacturer' => $manufacturer,
            'model' => $model,
            'os_release' => $osRelease,
            'sdk_int' => $sdkInt,
            'app_version' => $appVersion,
        ];
    }

    /** @param array<string, mixed> $ctx @return list<array{label: string, href: string}> */
    private static function deviceLinks(string $deviceId, string $issuesBase, string $projectBase, array $ctx): array {
        if ($deviceId === '') {
            return [];
        }
        $links = [];
        $issueCount = (int) ($ctx['issue_count'] ?? 0);
        $issuesLabel = $issueCount > 0 ? 'Issues (' . $issueCount . ')' : 'Issues';
        $links[] = [
            'label' => $issuesLabel,
            'href' => $issuesBase . '/?view=reports&q=' . rawurlencode($deviceId),
        ];
        if (!empty($ctx['latest_issue_id'])) {
            $links[] = [
                'label' => 'Latest issue #' . (int) $ctx['latest_issue_id'],
                'href' => $issuesBase . '/?view=report&id=' . (int) $ctx['latest_issue_id'],
            ];
        }
        $graphCount = (int) ($ctx['graph_session_count'] ?? 0);
        if ($graphCount > 0) {
            $links[] = [
                'label' => 'Graph sessions (' . $graphCount . ')',
                'href' => rtrim($projectBase, '/') . '/graphs/',
            ];
        }
        return $links;
    }

    private static function uuid4(): string {
        $b = random_bytes(16);
        $b[6] = chr((ord($b[6]) & 0x0f) | 0x40);
        $b[8] = chr((ord($b[8]) & 0x3f) | 0x80);
        $h = bin2hex($b);
        return substr($h, 0, 8) . '-' . substr($h, 8, 4) . '-' . substr($h, 12, 4)
            . '-' . substr($h, 16, 4) . '-' . substr($h, 20, 12);
    }
}
