import { Browser, BrowserContext, chromium } from 'playwright';
import { ChildProcess, spawn, execSync } from 'child_process';
import { mkdirSync, rmSync } from 'fs';
import { resolve } from 'path';
import { createServer } from 'net';
import { EventEmitter } from 'events';
import { logger } from '../logger.js';
import type { BrowserConfig } from '../config.js';
import { AsyncMutex } from './async-mutex.js';
import { buildLaunchOptions, applyStealthInitScript, applyAgentSnapshotHooks } from './browser-launch.js';

/**
 * CAPTCHA Pool は固定 ID で 1 個だけ。WebSearch / WebFetch スクショなど "タスク横断で
 * 共有したい認証/Cookie" を集約する場所。admin だけが noVNC で接続できる。
 */
export const CAPTCHA_POOL_SESSION_ID = '__captcha_pool__';

export interface BrowserSession {
  id: string;
  /**
   * 'pool': admin が CAPTCHA を解く共有 session
   * 'task': タスクごとに分離された session
   * 'login': capture flow 用の一時 session (browser session profile への storageState 取得)
   */
  kind: 'pool' | 'task' | 'login';
  /** kind === 'task' のとき、紐づくローカルタスクの ID */
  taskId?: string;
  /** kind === 'task' のとき、タスク owner ユーザーの ID */
  userId?: string;
  /** kind === 'login' (capture flow) or 'task' (replay): which browser session profile is bound. */
  profileId?: number;
  browser: Browser | null;
  context: BrowserContext | null;
  vncPort: number;
  novncPort: number;
  userDataDir: string;
  state: 'ready' | 'user_interactive' | 'agent_controlled';
  xvfbProcess: ChildProcess | null;
  x11vncProcess: ChildProcess | null;
  websockifyProcess: ChildProcess | null;
  display: string;
  createdAt: Date;
  /** LRU 退避 / アイドル GC の判定に使う。createSession / 利用検出ごとに更新される */
  lastActiveAt: Date;
  lockedByJobId: string | null;
  /** kind === 'pool' のみ。WebSearch が CAPTCHA を踏んだとき true。admin 解決後 false に戻す */
  captchaPending?: boolean;
}

/**
 * LRU で退避すべき task session を選ぶ。lockedByJobId が立っているものは除外する
 * (実行中ジョブの session を奪うとそのジョブが壊れるため)。
 * 純粋関数なのでユニットテストできる。
 */
export function selectOldestTaskSessionId(sessions: BrowserSession[]): string | null {
  let oldest: BrowserSession | null = null;
  for (const s of sessions) {
    if (s.kind !== 'task') continue;
    if (s.lockedByJobId) continue;
    if (!oldest || s.lastActiveAt.getTime() < oldest.lastActiveAt.getTime()) oldest = s;
  }
  return oldest?.id ?? null;
}

/**
 * idle GC の対象となる task session の id 一覧を返す。
 * 純粋関数なのでユニットテストできる。
 */
export function findIdleTaskSessions(
  sessions: BrowserSession[],
  ttlSec: number,
  now: Date,
): string[] {
  const cutoff = now.getTime() - ttlSec * 1000;
  return sessions
    .filter(s => s.kind === 'task' && !s.lockedByJobId && s.lastActiveAt.getTime() < cutoff)
    .map(s => s.id);
}

// Note: TOCTOU race between closing temp server and websockify bind is acceptable for this use case
function getFreePort(): Promise<number> {
  return new Promise((resolve, reject) => {
    const srv = createServer();
    srv.listen(0, '127.0.0.1', () => {
      const addr = srv.address();
      if (addr && typeof addr !== 'string') {
        const port = addr.port;
        srv.close(() => resolve(port));
      } else {
        srv.close(() => reject(new Error('Failed to get free port')));
      }
    });
    srv.on('error', reject);
  });
}

export class SessionManager extends EventEmitter {
  private sessions = new Map<string, BrowserSession>();
  private config: BrowserConfig;
  private nextDisplayNum = 99;
  private gcIntervalHandle: NodeJS.Timeout | null = null;
  private creationMutex = new AsyncMutex();

  constructor(config: BrowserConfig) {
    super();
    this.config = config;
    this.cleanupOrphanedProcesses();
  }

  static isAvailable(): boolean {
    // Use the POSIX shell builtin `command -v`, NOT the external `which` binary.
    // `which` is absent in some minimal images (e.g. debian-slim dropped it from
    // debianutils) and depends on PATH; relying on it caused "deps missing →
    // headless fallback" even when Xvfb/x11vnc/websockify were actually installed.
    // `command -v` is built into /bin/sh, so execSync (which runs via `/bin/sh -c`)
    // resolves each binary from the server process's own PATH.
    const required = ['Xvfb', 'x11vnc', 'websockify'];
    const missing: string[] = [];
    for (const bin of required) {
      try {
        execSync(`command -v ${bin}`, { stdio: 'ignore' });
      } catch {
        missing.push(bin);
      }
    }
    if (missing.length > 0) {
      logger.warn(
        `[SessionManager] noVNC display stack unavailable; not found on the server PATH: ${missing.join(', ')}. ` +
        `Browser/CAPTCHA live view (display_mode: novnc) falls back to headless. ` +
        `Install them (scripts/setup-novnc.sh) and ensure they are on the server process's PATH.`,
      );
      return false;
    }
    return true;
  }

  private cleanupOrphanedProcesses(): void {
    logger.info('[SessionManager] Cleaning up orphaned processes');
  }

  /**
   * 共通の session 立ち上げ処理。Xvfb / x11vnc / websockify / Playwright Browser を起動し、
   * BrowserSession レコードを sessions Map に登録する。
   *
   * 呼び出し元: createSession (legacy), createPoolSession, getOrCreateTaskSession
   */
  private async doCreateSessionInternal(opts: {
    id?: string;
    kind: 'pool' | 'task' | 'login';
    taskId?: string;
    userId?: string;
    profileId?: number;
    storageState?: object;
  }): Promise<BrowserSession> {
    // Pool / login は maxSessions のカウント外。task のみ上限チェック。
    if (opts.kind === 'task') {
      const taskCount = Array.from(this.sessions.values()).filter(s => s.kind === 'task').length;
      const max = this.config.maxSessions ?? 5;
      if (taskCount >= max) {
        throw new Error('Maximum number of browser sessions reached');
      }
    }

    const id = opts.id ?? crypto.randomUUID();
    const displayNum = this.nextDisplayNum++;
    const display = `:${displayNum}`;
    const vncPort = (this.config.vncBasePort ?? 5900) + (displayNum - 99);
    const novncPort = await getFreePort();
    const userDataDir = resolve(this.config.sessionDataDir ?? './data/browser-sessions', id);
    mkdirSync(userDataDir, { recursive: true });

    const xvfbProcess = spawn('Xvfb', [display, '-screen', '0', '1280x720x24'], { stdio: 'ignore' });
    await new Promise(r => setTimeout(r, 500));
    const x11vncProcess = spawn('x11vnc', ['-display', display, '-rfbport', String(vncPort), '-nopw', '-forever', '-shared'], { stdio: 'ignore' });

    // websockify は legacy 版 (Ubuntu/Debian 標準) では --listen-host を解釈できず即死する。
    // source_addr:port 形式は legacy/新 どちらでも動く。stderr は logger に流して spawn
    // 失敗を可視化する (将来また即死した時にここで気づけるように)。
    const websockifyProcess = spawn('websockify', [
      `127.0.0.1:${novncPort}`,
      `localhost:${vncPort}`,
    ], { stdio: ['ignore', 'ignore', 'pipe'] });
    websockifyProcess.stderr?.on('data', (chunk: Buffer) => {
      const msg = chunk.toString('utf-8').trimEnd();
      if (msg) logger.warn(`[SessionManager] websockify[${id.slice(0, 8)}] stderr: ${msg}`);
    });
    websockifyProcess.on('exit', (code, signal) => {
      if (code !== 0 && code !== null) {
        logger.warn(`[SessionManager] websockify[${id.slice(0, 8)}] exited code=${code} signal=${signal ?? ''}`);
      }
    });

    const prevDisplay = process.env.DISPLAY;
    process.env.DISPLAY = display;
    const browser = await chromium.launch(buildLaunchOptions(this.config, false));
    if (prevDisplay !== undefined) process.env.DISPLAY = prevDisplay;
    else delete process.env.DISPLAY;
    const contextOpts: Parameters<Browser['newContext']>[0] = { userAgent: 'Mozilla/5.0' };
    if (opts.storageState) contextOpts.storageState = opts.storageState as never;
    const context = await browser.newContext(contextOpts);
    await applyStealthInitScript(context);
    await applyAgentSnapshotHooks(context);

    const now = new Date();
    const session: BrowserSession = {
      id,
      kind: opts.kind,
      taskId: opts.taskId,
      userId: opts.userId,
      profileId: opts.profileId,
      browser, context,
      vncPort, novncPort, userDataDir,
      state: 'ready',
      xvfbProcess, x11vncProcess, websockifyProcess,
      display,
      createdAt: now,
      lastActiveAt: now,
      lockedByJobId: null,
    };

    this.sessions.set(id, session);
    logger.info(`[SessionManager] Session ${id} (kind=${opts.kind}${opts.taskId ? ` taskId=${opts.taskId}` : ''}) created on display ${display}, internal websockify port ${novncPort}`);
    return session;
  }

  private async createSessionInternal(opts: {
    id?: string;
    kind: 'pool' | 'task' | 'login';
    taskId?: string;
    userId?: string;
    profileId?: number;
    storageState?: object;
  }): Promise<BrowserSession> {
    // Serialize creation: doCreateSessionInternal mutates process.env.DISPLAY
    // across an await (chromium.launch). Concurrent callers (now possible with
    // worker max_concurrency > 1) would otherwise clobber each other's DISPLAY.
    return this.creationMutex.runExclusive(() => this.doCreateSessionInternal(opts));
  }

  /**
   * @deprecated 新規コードでは createPoolSession() / getOrCreateTaskSession() を使う。
   * 後方互換: 既存の InteractiveBrowse / browser-api.createBrowserApi が直接呼んでいる。
   */
  async createSession(userId?: string): Promise<BrowserSession> {
    return this.createSessionInternal({ kind: 'task', userId });
  }

  /**
   * CAPTCHA Pool session を取得 (なければ作る)。固定 ID で 1 個だけ。
   * 既存 session の browser が disconnect していたら作り直す。
   */
  async createPoolSession(): Promise<BrowserSession> {
    const existing = this.sessions.get(CAPTCHA_POOL_SESSION_ID);
    if (existing && existing.browser?.isConnected()) {
      existing.lastActiveAt = new Date();
      return existing;
    }
    if (existing) {
      // browser が死んでいる残骸を片付ける
      await this.destroySession(CAPTCHA_POOL_SESSION_ID);
    }
    return this.createSessionInternal({
      id: CAPTCHA_POOL_SESSION_ID,
      kind: 'pool',
    });
  }

  /**
   * 指定 taskId の task session を取得 (なければ作る)。max 超過時は最古の task session を退避。
   */
  async getOrCreateTaskSession(taskId: string, userId?: string): Promise<BrowserSession> {
    for (const s of this.sessions.values()) {
      if (s.kind === 'task' && s.taskId === taskId && s.browser?.isConnected()) {
        s.lastActiveAt = new Date();
        return s;
      }
    }

    const taskCount = Array.from(this.sessions.values()).filter(s => s.kind === 'task').length;
    const max = this.config.maxSessions ?? 5;
    if (taskCount >= max) {
      const oldestId = selectOldestTaskSessionId(Array.from(this.sessions.values()));
      if (oldestId) {
        logger.info(`[SessionManager] LRU evict task session ${oldestId} to make room for taskId=${taskId}`);
        await this.destroySession(oldestId);
      }
    }

    return this.createSessionInternal({ kind: 'task', taskId, userId });
  }

  /**
   * Spawn a fresh interactive noVNC session for a user to log into a site so its
   * storageState can be captured into a browser_session_profile.
   */
  async createLoginSession(opts: { ownerId: string; profileId: number }): Promise<BrowserSession> {
    return this.createSessionInternal({
      kind: 'login',
      userId: opts.ownerId,
      profileId: opts.profileId,
    });
  }

  /**
   * Get-or-create a task session, optionally pre-loading storageState. If a session for
   * the taskId already exists, the storageState is ignored (it was applied at first
   * creation; we reuse the live context).
   */
  async getOrCreateTaskSessionWithState(
    taskId: string,
    userId: string | undefined,
    storageState: object | null,
    profileId: number | null,
  ): Promise<BrowserSession> {
    for (const s of this.sessions.values()) {
      if (s.kind === 'task' && s.taskId === taskId && s.browser?.isConnected()) {
        s.lastActiveAt = new Date();
        return s;
      }
    }
    const taskCount = Array.from(this.sessions.values()).filter(s => s.kind === 'task').length;
    const max = this.config.maxSessions ?? 5;
    if (taskCount >= max) {
      const oldestId = selectOldestTaskSessionId(Array.from(this.sessions.values()));
      if (oldestId) await this.destroySession(oldestId);
    }
    return this.createSessionInternal({
      kind: 'task', taskId, userId,
      profileId: profileId ?? undefined,
      storageState: storageState ?? undefined,
    });
  }

  /** session の lastActiveAt を更新する。BrowseWeb / WebSearch 等が呼ぶ */
  touchSession(id: string): void {
    const s = this.sessions.get(id);
    if (s) s.lastActiveAt = new Date();
  }

  /** Pool の captchaPending フラグを設定する */
  markCaptchaPending(pending: boolean): void {
    const pool = this.sessions.get(CAPTCHA_POOL_SESSION_ID);
    if (pool) pool.captchaPending = pending;
  }

  /** アイドル GC を起動する。サーバー起動時に 1 回呼ぶ */
  startIdleGc(intervalMs = 60_000): void {
    if (this.gcIntervalHandle) return;
    this.gcIntervalHandle = setInterval(() => {
      const ttlSec = this.config.taskSessionIdleTtl ?? 300;
      const ids = findIdleTaskSessions(Array.from(this.sessions.values()), ttlSec, new Date());
      for (const id of ids) {
        logger.info(`[SessionManager] idle GC destroying task session ${id} (idle > ${ttlSec}s)`);
        this.destroySession(id).catch(e => logger.warn(`[SessionManager] idle GC destroy ${id} failed: ${e}`));
      }
    }, intervalMs);
    if (typeof this.gcIntervalHandle.unref === 'function') this.gcIntervalHandle.unref();
  }

  /** アイドル GC を停止する。サーバー shutdown 時 / テスト時に呼ぶ */
  stopIdleGc(): void {
    if (this.gcIntervalHandle) {
      clearInterval(this.gcIntervalHandle);
      this.gcIntervalHandle = null;
    }
  }

  getSession(id: string): BrowserSession | undefined {
    return this.sessions.get(id);
  }

  listSessions(): BrowserSession[] {
    return Array.from(this.sessions.values());
  }

  async destroySession(id: string): Promise<void> {
    const session = this.sessions.get(id);
    if (!session) return;
    try { await session.context?.close(); } catch {}
    try { await session.browser?.close(); } catch {}
    session.websockifyProcess?.kill();
    session.x11vncProcess?.kill();
    session.xvfbProcess?.kill();
    if (session.userDataDir) {
      try {
        rmSync(session.userDataDir, { recursive: true, force: true });
      } catch (e) {
        logger.warn(`[SessionManager] failed to rm userDataDir ${session.userDataDir}: ${(e as Error).message}`);
      }
    }
    this.sessions.delete(id);
    logger.info(`[SessionManager] Session ${id} destroyed`);
  }

  lockSession(sessionId: string, jobId: string): boolean {
    const session = this.sessions.get(sessionId);
    if (!session || session.lockedByJobId) return false;
    session.lockedByJobId = jobId;
    return true;
  }

  unlockSession(sessionId: string): void {
    const session = this.sessions.get(sessionId);
    if (session) session.lockedByJobId = null;
  }

  releaseToAgent(sessionId: string): void {
    const session = this.sessions.get(sessionId);
    if (session) {
      session.state = 'agent_controlled';
      this.emit('session-released', sessionId);
    }
  }

  async destroyAll(): Promise<void> {
    this.stopIdleGc();
    for (const id of this.sessions.keys()) {
      await this.destroySession(id);
    }
  }
}
