<?php

declare(strict_types=1);

namespace Atdt;

/**
 * Caller sessions and nodes (SPEC §9.2, §9.5 — M1 slice).
 *
 * M1 covers connect/reap/close and node allocation so the matrix is reachable;
 * the screen-stack engine, login, and time accounting build on this in M2/M3.
 *
 * call_log linkage: a session's call_log row is keyed by (node, on_at) where
 * on_at == sessions.created_at, so no extra schema column is needed.
 */
final class BbsSession
{
    /** Seconds of silence after which a session is considered dead (§9.1.4). */
    public const REAP_AFTER = 90;

    /**
     * Shown to callers while board_offline=1 when the sysop leaves the
     * message box empty (1.7.0). Pipe codes render; settings.php shows the
     * same text as the textarea placeholder.
     */
    public const OFFLINE_MSG_DEFAULT = 'The board is closed for maintenance. Back soon.';

    /** True when the sysop has flipped the board offline (no exceptions, any level). */
    public static function offline(Settings $settings): bool
    {
        return (string)$settings->get('board_offline', '0') === '1';
    }

    /** The offline notice, sysop's wording or the stock line. Raw pipe codes. */
    public static function offlineMsg(Settings $settings): string
    {
        $msg = trim((string)$settings->get('board_offline_msg', ''));
        return $msg !== '' ? $msg : self::OFFLINE_MSG_DEFAULT;
    }

    /** @var array<int, resource> keep flock handles alive for the request */
    private static array $locks = [];

    /**
     * Serialize handling per session: flock on data/locks/sess_<id>.lock.
     * The lock releases automatically at request end.
     */
    public static function lock(int $sessionId): void
    {
        if (isset(self::$locks[$sessionId])) {
            return;
        }
        $dir = ATDT_ROOT . '/data/locks';
        if (!is_dir($dir)) {
            @mkdir($dir, 0755, true);
        }
        $fh = @fopen($dir . '/sess_' . $sessionId . '.lock', 'c');
        if ($fh === false) {
            return; // degraded but functional; single-writer risk only
        }
        flock($fh, LOCK_EX);
        self::$locks[$sessionId] = $fh;
    }

    /** Guard multi-step critical sections shared by all sessions (node allocation). */
    public static function globalLock(string $name): mixed
    {
        $dir = ATDT_ROOT . '/data/locks';
        if (!is_dir($dir)) {
            @mkdir($dir, 0755, true);
        }
        $fh = @fopen($dir . '/' . $name . '.lock', 'c');
        if ($fh !== false) {
            flock($fh, LOCK_EX);
        }
        return $fh;
    }

    public static function unlockGlobal(mixed $fh): void
    {
        if (is_resource($fh)) {
            flock($fh, LOCK_UN);
            fclose($fh);
        }
    }

    /** Mark sessions silent for REAP_AFTER seconds inactive; close their call_log rows. */
    /**
     * The ban list (1.1.0): one address per line in the ip_bans setting.
     * Exact match, a trailing * for a prefix ("203.0.113.*"), or a #
     * comment. Checked before the connect ceremony and on every request.
     */
    public static function ipBanned(string $ip, Settings $settings): bool
    {
        $list = trim((string)$settings->get('ip_bans', ''));
        if ($list === '' || $ip === '') {
            return false;
        }
        $ip = strtolower($ip);
        foreach (preg_split('/\R+/', $list) ?: [] as $line) {
            $pat = strtolower(trim($line));
            if ($pat === '' || $pat[0] === '#') {
                continue;
            }
            if (str_ends_with($pat, '*')) {
                if (str_starts_with($ip, substr($pat, 0, -1))) {
                    return true;
                }
            } elseif ($ip === $pat) {
                return true;
            }
        }
        return false;
    }

    public static function reap(Db $db, Settings $settings): int
    {
        $cutoff = gmdate('Y-m-d\TH:i:s\Z', time() - self::REAP_AFTER);
        $dead = $db->all(
            'SELECT * FROM sessions WHERE active = 1 AND last_seen < ?',
            [$cutoff]
        );
        foreach ($dead as $sess) {
            self::close($db, $settings, $sess);
        }
        return count($dead);
    }

    /** Lowest free node 1..max_nodes, or null if the board is full. */
    public static function allocateNode(Db $db, int $maxNodes): ?int
    {
        $taken = array_map(
            'intval',
            array_column($db->all('SELECT node FROM sessions WHERE active = 1'), 'node')
        );
        for ($n = 1; $n <= $maxNodes; $n++) {
            if (!in_array($n, $taken, true)) {
                return $n;
            }
        }
        return null;
    }

    /**
     * Create a session + its call_log row; returns the session row.
     * @return array<string, mixed>
     */
    public static function create(Db $db, int $node, string $ip): array
    {
        $now = now_utc();
        $token = bin2hex(random_bytes(32)); // §19.3: 32 random bytes, hex
        $id = $db->insert('sessions', [
            'token' => $token,
            'user_id' => null,
            'node' => $node,
            'state' => json_encode([['screen' => 'Matrix', 'd' => new \stdClass()]]),
            'seq' => 0,
            'ip' => $ip,
            'created_at' => $now,
            'last_seen' => $now,
            'active' => 1,
        ]);
        $db->insert('call_log', [
            'user_id' => null,
            'handle' => '(matrix)',
            'node' => $node,
            'on_at' => $now,
            'summary' => '',
        ]);
        return [
            'id' => $id, 'token' => $token, 'user_id' => null, 'node' => $node,
            'state' => '[]', 'seq' => 0, 'ip' => $ip,
            'created_at' => $now, 'last_seen' => $now, 'active' => 1,
        ];
    }

    /** Find a live session by its token. @return array<string, mixed>|null */
    public static function byToken(Db $db, string $token): ?array
    {
        if (!preg_match('/^[0-9a-f]{64}$/', $token)) {
            return null;
        }
        return $db->one('SELECT * FROM sessions WHERE token = ? AND active = 1', [$token]);
    }


    /**
     * End a session (graceful logoff or reap): free the node, close the
     * call_log row, count the call (§10.7).
     * @param array<string, mixed> $sess
     */
    public static function close(Db $db, Settings $settings, array $sess): void
    {
        $done = $db->q(
            'UPDATE sessions SET active = 0, last_seen = ? WHERE id = ? AND active = 1',
            [now_utc(), (int)$sess['id']]
        )->rowCount();
        if ($done === 0) {
            return; // someone else already closed it; don't double-count the call
        }
        $db->q(
            'UPDATE call_log SET off_at = ? WHERE off_at IS NULL AND node = ? AND on_at = ?',
            [now_utc(), (int)$sess['node'], (string)$sess['created_at']]
        );
        // String arithmetic works in both engines for this counter.
        $db->q("UPDATE settings SET v = v + 1 WHERE k = 'total_calls'");
    }

    /**
     * Hang up every live caller at once (console "disconnect everyone",
     * 1.7.0). Each session closes gracefully -- call_log row finished,
     * call counted -- and the caller's next poll answers "no_session",
     * which the terminal renders as a dropped carrier. Returns how many
     * lines went dark.
     */
    public static function closeAll(Db $db, Settings $settings): int
    {
        $live = $db->all('SELECT * FROM sessions WHERE active = 1');
        foreach ($live as $sess) {
            self::close($db, $settings, $sess);
        }
        return count($live);
    }
}
