/**
 * Shared tmux utilities for terminal servers.
 *
 * Extracted from direct-terminal-ws.ts and terminal-websocket.ts
 * so the logic can be properly unit tested.
 */
import { execFile, execFileSync } from "node:child_process";
import { readdirSync, existsSync, readFileSync } from "node:fs";
import { homedir } from "node:os";
import { join } from "node:path";
import { promisify } from "node:util";
import { isWindows } from "@aoagents/ao-core";
const execFileAsync = promisify(execFile);
/** Session ID validation regex — alphanumeric, hyphens, underscores only */
export const SESSION_ID_PATTERN = /^[a-zA-Z0-9_-]+$/;
/** Hash prefix pattern — 12-char lowercase hex, as generated by generateConfigHash */
const HASH_PREFIX_PATTERN = /^[a-f0-9]{12}-/;
/**
 * StorageKey pattern — either bare 12-hex hash or `{hash}-{projectName}`
 * wrapped form. The wrapped suffix comes from `basename(projectPath)` on
 * disk so it can contain any character a filesystem path component allows
 * (spaces, unicode, etc.); we only require that something non-empty
 * follows the `-` separator. Security: storageKey is passed to
 * `execFileSync` which bypasses the shell, so arbitrary characters are
 * safe in the argv.
 */
const STORAGE_KEY_PATTERN = /^[a-f0-9]{12}(-.+)?$/;
const defaultFs = {
    // Only return subdirectory names. `readdirSync` without withFileTypes
    // includes plain files, so a stray file like `aabbccddeef0` would pass
    // STORAGE_KEY_PATTERN and trigger an unnecessary existsSync probe.
    readdir: (p) => readdirSync(p, { withFileTypes: true })
        .filter((e) => e.isDirectory())
        .map((e) => e.name),
    exists: (p) => existsSync(p),
    homedir,
};
/**
 * Find every storageKey that owns a given sessionId by scanning the AO
 * base directory for projects whose `sessions/{sessionId}` file exists.
 *
 * This is the authoritative disambiguation step: tmux names alone are
 * ambiguous when storageKey can take either the bare-hash or wrapped
 * `{hash}-{projectName}` form. The on-disk session record is unique per
 * storageKey so it tells us exactly which tmux name to expect.
 *
 * Returns all candidates (not just the first). Multiple projects can
 * share a sessionId like `app-1`, and the caller must probe each until
 * it finds a live tmux session — otherwise a stale metadata dir from
 * one project could shadow the live session of another.
 */
function findStorageKeysForSession(sessionId, fs, projectId) {
    const aoBase = join(fs.homedir(), ".agent-orchestrator");
    let entries;
    try {
        entries = fs.readdir(aoBase);
    }
    catch {
        return [];
    }
    const matches = [];
    const projectMatches = [];
    for (const entry of entries) {
        if (!STORAGE_KEY_PATTERN.test(entry))
            continue;
        const sessionFile = join(aoBase, entry, "sessions", sessionId);
        if (fs.exists(sessionFile)) {
            const unwrappedProjectId = entry.slice(13); // Strip "{hash}-" prefix when present.
            if (projectId && (entry === projectId || unwrappedProjectId === projectId)) {
                projectMatches.push(entry);
            }
            else {
                matches.push(entry);
            }
        }
    }
    return [...projectMatches, ...matches];
}
/**
 * Validate a session ID format.
 * Prevents path traversal, shell injection, and other attacks.
 */
export function validateSessionId(sessionId) {
    return SESSION_ID_PATTERN.test(sessionId);
}
/**
 * Find full path to tmux binary.
 *
 * Checks common installation locations because node-pty's posix_spawnp
 * doesn't reliably inherit PATH, and some Node.js environments (e.g.,
 * launched from GUI apps) have minimal PATH.
 *
 * @param execFn - Injectable execFileSync for testing. Defaults to child_process.execFileSync.
 */
export function findTmux(execFn = execFileSync) {
    if (isWindows())
        return null;
    const candidates = [
        "/opt/homebrew/bin/tmux", // macOS ARM (Homebrew)
        "/usr/local/bin/tmux", // macOS Intel (Homebrew)
        "/usr/bin/tmux", // Linux
    ];
    for (const p of candidates) {
        try {
            execFn(p, ["-V"], { timeout: 5000 });
            return p;
        }
        catch {
            continue;
        }
    }
    return "tmux"; // Fall back to bare name
}
/**
 * Check whether a tmux session with the given name exists.
 *
 * Uses `=` exact-match prefix so the lookup never falls back to tmux's
 * default prefix matching (where "ao-1" would match "ao-15"). The caller
 * must already have the canonical tmux session name (typically the value
 * returned by `resolveTmuxSession`).
 *
 * Async: this runs from inside node-pty's `onExit` callback on every agent
 * exit, and the WebSocket server is single-threaded. A synchronous
 * `execFileSync` here would block the event loop — and every WebSocket
 * connection, HTTP request, and in-flight terminal — for up to the 5 s
 * subprocess timeout when tmux is slow to respond. Use the promisified
 * `execFile` form instead.
 *
 * @returns true if the session exists, false otherwise (including tmux
 *   not running, no sessions, or any unexpected error)
 */
export async function tmuxHasSession(tmuxPath, tmuxSessionName, execFn = execFileAsync) {
    if (!tmuxPath)
        return false;
    try {
        await execFn(tmuxPath, ["has-session", "-t", `=${tmuxSessionName}`], { timeout: 5000 });
        return true;
    }
    catch {
        return false;
    }
}
/**
 * Resolve a user-facing session ID to its actual tmux session name.
 *
 * ao-core names tmux sessions as `{storageKey}-{sessionId}`, where
 * storageKey is either `{12-hex}` (bare hash) or `{12-hex}-{projectName}`
 * (legacy wrapped format). This function:
 *
 * 1. Tries exact match using tmux's `=` prefix syntax to prevent
 *    prefix matching (where "ao-1" would incorrectly match "ao-15").
 * 2. Looks up the storageKey owning this sessionId on disk (under
 *    `~/.agent-orchestrator/{storageKey}/sessions/{sessionId}`) and asks
 *    tmux whether the exact `{storageKey}-{sessionId}` session exists.
 *    The on-disk check is authoritative — it avoids ambiguous suffix
 *    matches where a bare session like `{hash}-my-app-1` could be
 *    mistaken for a lookup of `app-1`.
 * 3. Falls back to listing sessions and matching a hash-prefixed name
 *    whose remainder equals the sessionId (bare-hash only), so behavior
 *    stays correct even if the on-disk session record is absent.
 *
 * @param sessionId - User-facing session ID (e.g., "ao-15")
 * @param tmuxPath - Full path to tmux binary
 * @param execFn - Injectable execFileSync for testing. Defaults to child_process.execFileSync.
 * @param fs - Injectable filesystem adapter for testing.
 * @returns The actual tmux session name, or null if not found
 */
export function resolveTmuxSession(sessionId, tmuxPath, execFn = execFileSync, fs = defaultFs, projectId) {
    if (!tmuxPath)
        return null;
    // Try exact match first using = prefix for exact matching (e.g., "ao-orchestrator")
    // Without =, tmux uses prefix matching: "ao-1" would match "ao-15"
    try {
        execFn(tmuxPath, ["has-session", "-t", `=${sessionId}`], { timeout: 5000 });
        return sessionId;
    }
    catch {
        // Not an exact match
    }
    // Authoritative path: find candidate storageKeys on disk, then verify
    // each exact tmux session name with has-session. This is unambiguous
    // even when the storageKey is wrapped (`{hash}-{projectName}`). Walk
    // every candidate so a stale metadata dir in one project can't shadow
    // the live session of another project with the same sessionId.
    for (const storageKey of findStorageKeysForSession(sessionId, fs, projectId)) {
        const tmuxName = `${storageKey}-${sessionId}`;
        try {
            execFn(tmuxPath, ["has-session", "-t", `=${tmuxName}`], { timeout: 5000 });
            return tmuxName;
        }
        catch {
            // Session dir exists but tmux session doesn't — try next candidate
        }
    }
    // Fallback: list sessions and match the bare-hash form only. We
    // intentionally do NOT match by trailing suffix here — that would cause
    // `app-1` to falsely resolve a distinct session `{hash}-my-app-1`. If a
    // wrapped-storageKey session isn't findable on disk above, it's safer
    // to return null than to guess.
    try {
        const output = execFn(tmuxPath, ["list-sessions", "-F", "#{session_name}"], {
            timeout: 5000,
            encoding: "utf8",
        });
        const sessions = output.split("\n").filter(Boolean);
        const match = sessions.find((s) => HASH_PREFIX_PATTERN.test(s) && s.substring(13) === sessionId);
        if (match) {
            return match;
        }
    }
    catch {
        // tmux not running or no sessions
    }
    return null;
}
/**
 * Resolve a user-facing session ID to its Windows named pipe path.
 *
 * V2 layout (current): JSON metadata at
 *   `~/.agent-orchestrator/projects/{projectId}/sessions/{sessionId}.json`
 *   with `runtimeHandle.data.pipePath` as a top-level field.
 *
 * V1 layout (legacy fallback): line-delimited key=value at
 *   `~/.agent-orchestrator/{storageKey}/sessions/{sessionId}` where
 *   storageKey is bare 12-hex or `{hash}-{projectName}`. Kept so users
 *   who haven't run `ao migrate-storage` still see live sessions.
 *
 * When `projectId` is provided, only that project's metadata file is read.
 * Without it (legacy callers), walks all projects and returns the first
 * matching pipePath — which can collide when two projects share a sessionId.
 *
 * @returns Full pipe path (e.g., "\\\\.\\pipe\\ao-pty-win1-orchestrator"), or null
 */
export function resolvePipePath(sessionId, projectId, fs = defaultFs) {
    if (!isWindows())
        return null;
    const readFile = fs.readFile ?? ((p) => readFileSync(p, "utf8"));
    const aoBase = join(fs.homedir(), ".agent-orchestrator");
    const readPipeFromV2 = (project) => {
        const sessionFile = join(aoBase, "projects", project, "sessions", `${sessionId}.json`);
        if (!fs.exists(sessionFile))
            return null;
        try {
            const meta = JSON.parse(readFile(sessionFile));
            const pipePath = meta.runtimeHandle?.data?.pipePath;
            return typeof pipePath === "string" && pipePath.length > 0 ? pipePath : null;
        }
        catch {
            return null;
        }
    };
    // V2: prefer the caller's projectId when provided; otherwise walk all projects
    const projectsDir = join(aoBase, "projects");
    if (projectId) {
        const pipe = readPipeFromV2(projectId);
        if (pipe)
            return pipe;
    }
    else if (fs.exists(projectsDir)) {
        let projects;
        try {
            projects = fs.readdir(projectsDir);
        }
        catch {
            projects = [];
        }
        for (const project of projects) {
            const pipe = readPipeFromV2(project);
            if (pipe)
                return pipe;
        }
    }
    // V1 fallback: line-delimited key=value under {storageKey}/sessions/{sessionId}
    for (const storageKey of findStorageKeysForSession(sessionId, {
        readdir: fs.readdir,
        exists: fs.exists,
        homedir: fs.homedir,
    })) {
        const sessionFile = join(aoBase, storageKey, "sessions", sessionId);
        let content;
        try {
            content = readFile(sessionFile);
        }
        catch {
            continue;
        }
        const match = content.match(/^runtimeHandle=(.+)$/m);
        if (!match)
            continue;
        try {
            const handle = JSON.parse(match[1]);
            const pipePath = handle.data?.pipePath;
            if (pipePath && pipePath.length > 0)
                return pipePath;
        }
        catch {
            continue;
        }
    }
    return null;
}
