import { Command, Option } from 'commander';
import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from 'fs';
import { join, dirname, resolve } from 'path';
import { fileURLToPath } from 'url';
import { spawn, spawnSync, type ChildProcess } from 'child_process';
import { AuthStore } from '../lib/auth.js';
import { ApiError, GameClient } from '../lib/game-client.js';
import { getProfileStateDir } from '../lib/init-command.js';
import { EventStore } from '../pipeline/event-store.js';
import { spawnStrategyLoop } from '../strategies/spawn.js';
import { stopStrategyIfRunning } from '../strategies/strategy-loop.js';
import { setMeta } from '../lib/command-meta.js';
import { runStreaming, buildErrorLine, summarizeFeed, nextStepFor } from './watch.js';
import { hubReminder, readCachedGamesPlayed } from '../lib/hub-reminder.js';
import { noAccountMessage } from '../lib/account-guidance.js';
import { EventRuntime } from '../runtime/event-daemon.js';
import { RoomLobbyRuntime, type RoomGameStarted } from '../runtime/room-lobby-runtime.js';
import {
  apiErrorCode,
  apiErrorMessage,
  normalizeCompetitionStatusResponse,
  queueModeFrom,
  serializeError as serializeCompetitionError,
  type QueueMode,
} from '../lib/competition.js';
import {
  briefGameMap,
  summarizeGameMap,
} from '../lib/game-context.js';
import {
  gameStartRuntimePath,
  readGameStartRuntime,
  sendOwnerControlRequest,
  startOwnerControlServer,
  type OwnerControlInfo,
  type OwnerControlServer,
} from '../runtime/owner-control.js';
import {
  startMatch,
  endMatch,
  readMatchState,
  shouldEmitWaiting,
  markWaitingEmitted,
  getWaitedSecs,
  hasMatchTimedOut,
} from '../lib/match-state.js';
import {
  hasRoomQueueContext,
  isAlreadyInRoomError,
  isRoomAllocatedQueueStatus,
  parseApiErrorBody,
  queueRoomCode,
  queueMode as roomQueueMode,
  queueStatus as roomQueueStatus,
  roomCodeFromAlreadyInRoomError,
} from '../lib/room.js';

export { summarizeGameMap } from '../lib/game-context.js';

function sleep(ms: number): Promise<void> {
  return new Promise(r => setTimeout(r, ms));
}

function positiveNumber(value: string, fallback: number): number {
  const parsed = Number(value);
  return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
}

function queueStatus(result: any): string | undefined {
  return (result?.data ?? result)?.status;
}

function queueMode(result: any): QueueMode | undefined {
  return queueModeFrom(result);
}

type RuntimeGameMode = QueueMode | 'room';

const DEFAULT_QUEUE_WAIT_TIMEOUT_SECS = 30;

function clearCachedGameServerUrl(authStore: AuthStore, profileName?: string): void {
  try {
    authStore.updateGameServerUrl(undefined, profileName);
  } catch {
    // Cache cleanup is best-effort; queue status will discover the current server.
  }
}

function isLeaveGameSuccess(result: any): boolean {
  const data = result?.data ?? result;
  return result?.success === true || data?.success === true || data?.ok === true || data?.left === true || data?.message === 'left_game';
}

export function leaveGameFailureFromResult(result: any): Record<string, any> | undefined {
  const data = result?.data ?? result;
  const success = result?.success ?? data?.success;
  if (success !== false) return undefined;
  const error = result?.error ?? data?.error ?? {};
  const code = typeof error?.code === 'string'
    ? error.code
    : typeof data?.code === 'string'
      ? data.code
      : undefined;
  const message = typeof error?.message === 'string'
    ? error.message
    : typeof data?.message === 'string'
      ? data.message
      : 'Leave game failed';
  return cleanObject({
    code,
    message,
    still_alive: /STILL_ALIVE/i.test(`${code ?? ''} ${message ?? ''}`),
  });
}

export function roomStartFailureFromError(err: unknown, queue?: any): Record<string, any> | undefined {
  if (!(err instanceof ApiError)) return undefined;
  const parsed = parseApiErrorBody(err.body);
  const error = parsed?.error ?? {};
  if (error?.code !== 'NOT_ENOUGH_PLAYERS') return undefined;

  return cleanObject({
    ok: false,
    command: 'game start-room',
    error: 'NOT_ENOUGH_PLAYERS',
    message: typeof error.message === 'string' ? error.message : 'Need 10 players to start the room match.',
    shortage: error.shortage,
    queue_status: queue === undefined ? undefined : unwrapData(queue),
    next_step: 'The room is not full yet. Tell the user how many players are missing; do not leave the room or start normal matchmaking.',
  });
}

function isPidAlive(pid: number): boolean {
  if (!Number.isFinite(pid) || pid <= 0 || pid === process.pid) return false;
  try { process.kill(pid, 0); return true; } catch { return false; }
}

function getRunningGameStartPid(stateDir: string): number | null {
  const info = readGameStartRuntime(stateDir);
  if (!info) return null;
  const pid = Number(info?.owner_pid ?? info?.pid);
  if (isPidAlive(pid)) return pid;
  try { unlinkSync(gameStartRuntimePath(stateDir)); } catch {}
  return null;
}

function writeGameStartRuntime(
  stateDir: string,
  mode: GameStartPlanKind,
  phase?: string,
  controlOverride?: { control: OwnerControlInfo; token: string },
): void {
  mkdirSync(stateDir, { recursive: true });
  let startedAt = new Date().toISOString();
  let existingControl: OwnerControlInfo | undefined;
  let existingToken: string | undefined;
  try {
    const existing = JSON.parse(readFileSync(gameStartRuntimePath(stateDir), 'utf8'));
    const existingPid = Number(existing?.owner_pid ?? existing?.pid);
    if (existingPid === process.pid && typeof existing?.started_at === 'string') {
      startedAt = existing.started_at;
      if (existing?.control?.path) existingControl = existing.control;
      if (typeof existing?.control_token === 'string') existingToken = existing.control_token;
    }
  } catch {}
  const control = controlOverride?.control ?? existingControl;
  const controlToken = controlOverride?.token ?? existingToken;
  writeFileSync(gameStartRuntimePath(stateDir), JSON.stringify({
    schema: 3,
    owner_pid: process.pid,
    pid: process.pid,
    started_at: startedAt,
    heartbeat_at: new Date().toISOString(),
    mode,
    ...(control ? { control } : {}),
    ...(controlToken ? { control_token: controlToken } : {}),
    ...(phase ? { phase } : {}),
  }, null, 2));
}

function startGameStartHeartbeat(stateDir: string, mode: GameStartPlanKind): ReturnType<typeof setInterval> {
  return setInterval(() => {
    try {
      writeGameStartRuntime(stateDir, mode);
    } catch {}
  }, 5000);
}

function cleanupGameStartRuntime(stateDir: string, opts: { removeFeed?: boolean; controlPath?: string } = {}): void {
  const runtimePath = gameStartRuntimePath(stateDir);
  try {
    const info = JSON.parse(readFileSync(runtimePath, 'utf8'));
    const pid = Number(info?.owner_pid ?? info?.pid);
    if (pid !== process.pid) return;
  } catch {}
  try { unlinkSync(runtimePath); } catch {}
  if (opts.removeFeed) {
    try { unlinkSync(join(stateDir, 'feed.json')); } catch {}
  }
  if (opts.controlPath && process.platform !== 'win32') {
    try { unlinkSync(opts.controlPath); } catch {}
  }
}

function terminateProcessTree(pid: number, signal: NodeJS.Signals = 'SIGTERM'): boolean {
  if (!Number.isFinite(pid) || pid <= 0 || pid === process.pid) return false;
  if (process.platform === 'win32') {
    const result = spawnSync('taskkill', ['/PID', String(pid), '/T', '/F'], { stdio: 'ignore' });
    return result.status === 0;
  }
  const pkillSignal = signal === 'SIGKILL' ? '-KILL' : '-TERM';
  try { spawnSync('pkill', [pkillSignal, '-P', String(pid)], { stdio: 'ignore' }); } catch {}
  try {
    process.kill(pid, signal);
    return true;
  } catch {
    return false;
  }
}

async function waitPidExit(pid: number, timeoutMs = 5000): Promise<boolean> {
  const deadline = Date.now() + timeoutMs;
  while (Date.now() < deadline) {
    if (!isPidAlive(pid)) return true;
    await sleep(200);
  }
  return !isPidAlive(pid);
}

async function stopOwnerIfRunning(stateDir: string, timeoutMs = 5000): Promise<{ pid: number | null; stopped: boolean }> {
  return stopOwnerWithCommand(stateDir, 'stop', timeoutMs);
}

async function stopOwnerWithCommand(
  stateDir: string,
  command: 'stop' | 'quit' | 'leave',
  timeoutMs = 5000,
): Promise<{ pid: number | null; stopped: boolean }> {
  const pid = getRunningGameStartPid(stateDir);
  if (!pid) {
    try { unlinkSync(gameStartRuntimePath(stateDir)); } catch {}
    try { unlinkSync(join(stateDir, 'feed.json')); } catch {}
    return { pid: null, stopped: false };
  }
  try {
    const response = await sendOwnerControlRequest(stateDir, command);
    if (response?.ok) {
      const exited = await waitPidExit(pid, timeoutMs);
      if (exited) return { pid, stopped: true };
    }
  } catch {}
  terminateProcessTree(pid, 'SIGTERM');
  const exited = await waitPidExit(pid, timeoutMs);
  if (!exited) {
    terminateProcessTree(pid, 'SIGKILL');
    await waitPidExit(pid, 2000);
  }
  try { unlinkSync(gameStartRuntimePath(stateDir)); } catch {}
  try { unlinkSync(join(stateDir, 'feed.json')); } catch {}
  return { pid, stopped: true };
}

type QueueStatus = 'allocated' | 'queued' | 'not_in_queue' | string | undefined;

export type GameStartPlan =
  | { kind: 'already_running'; pid: number }
  | { kind: 'resume_room' }
  | { kind: 'resume_room_allocated' }
  | { kind: 'resume_queue' }
  | { kind: 'resume_allocated' }
  | { kind: 'fresh_start' };

type GameStartPlanKind = GameStartPlan['kind'];

export function planGameStartAction(input: {
  gameStartPid?: number | null;
  hasMatchState: boolean;
  queueStatus?: QueueStatus;
  queueMode?: string;
}): GameStartPlan {
  if (input.gameStartPid) return { kind: 'already_running', pid: input.gameStartPid };

  if (input.queueStatus === 'in_room') return { kind: 'resume_room' };
  if (input.queueStatus === 'allocated' && input.queueMode === 'room') return { kind: 'resume_room_allocated' };
  if (input.queueStatus === 'allocated') return { kind: 'resume_allocated' };
  if (input.queueStatus === 'queued' || input.queueStatus === 'already_in_queue') return { kind: 'resume_queue' };
  if (input.hasMatchState && input.queueStatus !== 'not_in_queue') return { kind: 'resume_queue' };
  return { kind: 'fresh_start' };
}

export type RoomJoinPlan =
  | { kind: 'join' }
  | { kind: 'resume_current_room'; roomCode: string }
  | { kind: 'leave_then_join'; roomCode: string }
  | { kind: 'refuse_room_allocated'; roomCode?: string };

export function planRoomJoinAction(input: {
  targetRoomCode: string;
  queueStatus?: QueueStatus;
  queueMode?: string;
  currentRoomCode?: string;
}): RoomJoinPlan {
  if (input.queueStatus === 'allocated' && input.queueMode === 'room') {
    return { kind: 'refuse_room_allocated', roomCode: input.currentRoomCode };
  }
  if (input.queueStatus !== 'in_room') return { kind: 'join' };
  if (!input.currentRoomCode) return { kind: 'join' };
  if (input.currentRoomCode === input.targetRoomCode) {
    return { kind: 'resume_current_room', roomCode: input.currentRoomCode };
  }
  return { kind: 'leave_then_join', roomCode: input.currentRoomCode };
}

export function roomJoinAlreadyRunningNextStep(pid: number): string {
  return `A room/game stream is already running (pid ${pid}). To enter another room, run ccl game quit first, wait for the current stream to exit, then start ccl game join-room <room_code> with Monitor.`;
}

export function planRoomEntryStreamGuard(input: {
  runningPid?: number | null;
  force?: boolean;
  source: 'room_create' | 'room_join';
}): { kind: 'continue' } | { kind: 'already_running'; pid: number; nextStep: string } {
  if (!input.runningPid || input.force) return { kind: 'continue' };
  if (input.source === 'room_join') {
    return {
      kind: 'already_running',
      pid: input.runningPid,
      nextStep: roomJoinAlreadyRunningNextStep(input.runningPid),
    };
  }
  return {
    kind: 'already_running',
    pid: input.runningPid,
    nextStep: `A room/game stream is already running (pid ${input.runningPid}). Do not start another stream; run ccl game quit first, or pass --force explicitly.`,
  };
}

const ROOM_SELF_LEFT_MESSAGE = 'The user left the room from the web page, or the current CLI quit command has left the room. Stop the room flow and do not create or join another room unless the user asks.';
const ROOM_SELF_LEFT_STREAM_MESSAGE = 'The user left the room from the web page, or the current CLI quit command has left the room. The room stream is exiting.';

function isAlreadyInGameError(err: unknown): boolean {
  return err instanceof ApiError && err.body.includes('ALREADY_IN_GAME');
}

function apiErrorBody(err: unknown): string {
  if (err instanceof ApiError) return err.body;
  if (err instanceof Error) return err.message;
  return String(err);
}

function isStillAliveError(err: unknown): boolean {
  return /STILL_ALIVE/i.test(apiErrorBody(err));
}

function serializeError(err: unknown): Record<string, any> {
  if (err instanceof ApiError) {
    return { status: err.status, body: err.body };
  }
  return { message: err instanceof Error ? err.message : String(err) };
}

function competitionExitReason(code: string | undefined): string {
  switch (code) {
    case 'NO_ACTIVE_COMPETITION': return 'no_active_competition';
    case 'COMPETITION_NOT_OPEN': return 'competition_not_open';
    case 'ALREADY_IN_GAME': return 'already_in_game';
    case 'INVALID_MODE': return 'invalid_mode';
    default: return 'request_failed';
  }
}

function competitionNextStep(code: string | undefined): string {
  switch (code) {
    case 'NO_ACTIVE_COMPETITION':
      return 'Tell the user there is no active competition. Do not start normal matchmaking unless the user asks.';
    case 'COMPETITION_NOT_OPEN':
      return 'Tell the user competition is not open now and use competition.daily_windows for the opening windows. Do not start normal matchmaking unless the user asks.';
    case 'ALREADY_IN_GAME':
      return 'Tell the user this account is already in a game. Reconnect or continue the existing game instead of starting another match.';
    default:
      return 'Tell the user competition matchmaking failed. Do not start normal matchmaking unless the user asks.';
  }
}

type GamePresence = 'in_game' | 'matching' | 'in_room' | 'none' | 'unknown';

interface GamePresenceInfo {
  presence: GamePresence;
  game_state?: any;
  queue_status?: any;
  owner_pid: number | null;
  errors?: Array<{ source: string; error: Record<string, any> }>;
}

function isMatchingQueueStatus(status: QueueStatus): boolean {
  return status === 'queued'
    || status === 'already_in_queue'
    || status === 'allocating'
    || status === 'allocated';
}

async function detectGamePresence(client: GameClient, stateDir: string): Promise<GamePresenceInfo> {
  const errors: Array<{ source: string; error: Record<string, any> }> = [];
  const ownerPid = getRunningGameStartPid(stateDir);
  let gameState: any | null = null;
  let queue: any;
  try {
    await client.discoverGameServer();
    gameState = await client.getGameState();
  } catch (err) {
    errors.push({ source: 'game_current', error: serializeError(err) });
  }
  try {
    queue = await client.getQueueStatus('clawclaw');
  } catch (err) {
    errors.push({ source: 'queue_status', error: serializeError(err) });
  }
  if (gameState) {
    return {
      presence: 'in_game',
      game_state: gameState,
      ...(queue ? { queue_status: queue } : {}),
      owner_pid: ownerPid,
      ...(errors.length ? { errors } : {}),
    };
  }

  const status = queueStatus(queue);
  if (status === 'in_room') {
    return {
      presence: 'in_room',
      queue_status: queue,
      owner_pid: ownerPid,
      ...(errors.length ? { errors } : {}),
    };
  }
  if (isMatchingQueueStatus(status)) {
    return {
      presence: 'matching',
      queue_status: queue,
      owner_pid: ownerPid,
      ...(errors.length ? { errors } : {}),
    };
  }
  if (status === 'not_in_queue') {
    return {
      presence: 'none',
      queue_status: queue,
      owner_pid: ownerPid,
      ...(errors.length ? { errors } : {}),
    };
  }
  return {
    presence: errors.length >= 2 ? 'unknown' : 'none',
    ...(queue ? { queue_status: queue } : {}),
    owner_pid: ownerPid,
    ...(errors.length ? { errors } : {}),
  };
}

function ensureEventSession(source: string): EventStore {
  const existing = EventStore.latestSessionPath();
  if (existing) {
    const events = EventStore.forActiveAccount();
    events.append({ type: 'session_resumed', source });
    return events;
  }
  const events = startEventSession(source);
  return events;
}

function startEventSession(source: string, extra: Record<string, any> = {}): EventStore {
  const events = EventStore.createSessionForActiveAccount();
  events.append({ type: 'session_started', source, ...extra });
  return events;
}

function nonEmptyString(value: unknown): string | undefined {
  return typeof value === 'string' && value.length > 0 ? value : undefined;
}

function unwrapData(value: any): any {
  return value?.data ?? value;
}

export function lobbyWatchUrl(profile: { serverUrl: string; apiKey: string }): string {
  const origin = new URL(profile.serverUrl).origin;
  return `${origin}/lobby?token=${encodeURIComponent(profile.apiKey)}`;
}

export function joinedLifecyclePayload(joinResult: any, joinedQueue: any): Record<string, any> {
  const data = unwrapData(joinResult);
  const url = nonEmptyString(data?.spectate_url) ?? nonEmptyString(data?.url);
  return {
    ...joinResult,
    ...(url ? { url } : {}),
    queue: joinedQueue,
  };
}

function cleanObject<T extends Record<string, any>>(obj: T): T {
  for (const key of Object.keys(obj)) {
    if (obj[key] === undefined) delete obj[key];
  }
  return obj;
}

function applyRoomGameServer(client: GameClient, event: RoomGameStarted): void {
  if (event.ws_url && typeof event.ws_url === 'string') {
    client.setGameServerUrl(event.ws_url.replace(/^wss:/, 'https:').replace(/^ws:/, 'http:'));
    return;
  }
  if (event.game_server_name) {
    client.setRoomGameServer(event.game_server_name);
  }
}

export interface GameStrategyIdentity {
  gameId?: string;
  role?: string;
  alive: boolean | null;
}

type InitialGameContext = {
  state: any | null;
  role: any | null;
  map: any | null;
  tasks: any[] | null;
  errors?: Array<{ command: string; error: string }>;
};

export function gameStrategyIdentity(stateData: any, roleData: any): GameStrategyIdentity {
  const state = unwrapData(stateData);
  const role = unwrapData(roleData);
  const you = state?.you ?? {};
  const aliveRaw = you?.is_alive ?? you?.alive;
  return {
    gameId: nonEmptyString(state?.game_id) ?? nonEmptyString(state?.game?.id) ?? nonEmptyString(state?.game?.game_id),
    role: nonEmptyString(role?.role) ?? nonEmptyString(you?.role),
    alive: typeof aliveRaw === 'boolean' ? aliveRaw : null,
  };
}

function commandError(command: string, err: unknown): { command: string; error: string } {
  const message = err instanceof ApiError
    ? `${err.status}: ${err.body}`
    : err instanceof Error
      ? err.message
      : String(err);
  return { command, error: message };
}

async function fetchInitialGameContext(client: GameClient, delayMs = 500): Promise<InitialGameContext> {
  if (delayMs > 0) await sleep(delayMs);
  const [stateResult, roleResult, mapResult] = await Promise.allSettled([
    client.getGameState(),
    client.getRoleInfo(),
    client.getMap(),
  ]);

  const errors: Array<{ command: string; error: string }> = [];
  if (stateResult.status === 'rejected') errors.push(commandError('state', stateResult.reason));
  if (roleResult.status === 'rejected') errors.push(commandError('game role', roleResult.reason));
  if (mapResult.status === 'rejected') errors.push(commandError('game map', mapResult.reason));

  const mapData = mapResult.status === 'fulfilled' ? mapResult.value : null;
  const context = {
    state: stateResult.status === 'fulfilled' ? stateResult.value : null,
    role: roleResult.status === 'fulfilled' ? roleResult.value : null,
    map: briefGameMap(mapData),
    tasks: mapData?.your_tasks ?? null,
    ...(errors.length ? { errors } : {}),
  };
  return context;
}

function roleIdFromRoleInfo(roleData: any): string | undefined {
  return roleData?.data?.role ?? roleData?.role;
}

async function waitForRoleInfo(
  client: GameClient,
  timeoutMs = 30_000,
  intervalMs = 500,
): Promise<{ role: any | null; errors?: Array<{ command: string; error: string }>; timedOut: boolean }> {
  const deadline = Date.now() + timeoutMs;
  let lastError: { command: string; error: string } | undefined;

  while (true) {
    try {
      const role = await client.getRoleInfo();
      if (roleIdFromRoleInfo(role)) return { role, timedOut: false };
      lastError = { command: 'game role', error: 'Role info response did not include a role field.' };
    } catch (err) {
      lastError = commandError('game role', err);
    }

    if (Date.now() >= deadline) {
      return { role: null, ...(lastError ? { errors: [lastError] } : {}), timedOut: true };
    }
    await sleep(Math.min(intervalMs, Math.max(0, deadline - Date.now())));
  }
}

async function fetchGameContextWithRole(client: GameClient, role: any): Promise<InitialGameContext> {
  const [stateResult, mapResult] = await Promise.allSettled([
    client.getGameState(),
    client.getMap(),
  ]);

  const errors: Array<{ command: string; error: string }> = [];
  if (stateResult.status === 'rejected') errors.push(commandError('state', stateResult.reason));
  if (mapResult.status === 'rejected') errors.push(commandError('game map', mapResult.reason));

  const mapData = mapResult.status === 'fulfilled' ? mapResult.value : null;
  return {
    state: stateResult.status === 'fulfilled' ? stateResult.value : null,
    role,
    map: briefGameMap(mapData),
    tasks: mapData?.your_tasks ?? null,
    ...(errors.length ? { errors } : {}),
  };
}

const ROLE_DEFAULT_STRATEGY: Record<string, string> = {
  'shrimp_generic': 'task-report',
  'shrimp_warrior': 'task-report',
  'shrimp_pistol': 'task-report',
  'crab_generic': 'crab-sabotage',
  'neutral_paradise_fish': 'corpse-patrol',
  'neutral_octopus': 'lone-kill-task',
};

function autoStartStrategy(roleData: any, stateData?: any, gameId?: string): { strategy: string; pid: number | undefined; child: ChildProcess } | null {
  const roleId = roleIdFromRoleInfo(roleData);
  if (!roleId) return null;
  const strategyId = ROLE_DEFAULT_STRATEGY[roleId];
  if (!strategyId) return null;
  const child = spawnStrategyLoop(strategyId, [roleId], {
    source: 'auto_start',
    gameId: gameId ?? gameStrategyIdentity(stateData, roleData).gameId,
    role: roleId,
    detached: false,
    writeRuntimeFiles: false,
  });
  return { strategy: strategyId, pid: child.pid, child };
}

async function runGameQuit(invokedAs = 'game quit'): Promise<void> {
  const authStore = new AuthStore();
  const profile = authStore.getActive();
  if (!profile) throw new Error('Not logged in.');

  const stateDir = getProfileStateDir(profile);
  const client = GameClient.fromAuth();
  const presence = await detectGamePresence(client, stateDir);
  let left: 'game' | 'game_returned_room' | 'room' | 'queue' | 'local_runtime' | 'none' = presence.owner_pid ? 'local_runtime' : 'none';
  let gameResult: any;
  let queueResult: any;
  let roomResult: any;
  let leaveError: Record<string, any> | undefined;
  const roomGame = hasRoomQueueContext(presence.queue_status);

  if (presence.presence === 'in_game') {
    try {
      gameResult = await client.leaveGame();
      const gameFailure = leaveGameFailureFromResult(gameResult);
      if (gameFailure) {
        leaveError = gameFailure;
        left = 'local_runtime';
      } else {
        if (isLeaveGameSuccess(gameResult)) clearCachedGameServerUrl(authStore, profile.agentName);
        left = roomGame ? 'game_returned_room' : 'game';
      }
    } catch (err) {
      leaveError = {
        ...serializeError(err),
        still_alive: isStillAliveError(err),
      };
      left = 'local_runtime';
    }
  } else if (presence.presence === 'in_room') {
    try {
      roomResult = await client.leaveRoom();
      left = 'room';
    } catch (err) {
      leaveError = serializeError(err);
    }
  } else if (presence.presence === 'matching') {
    try {
      queueResult = await client.leaveQueue('clawclaw');
      left = 'queue';
    } catch (err) {
      leaveError = serializeError(err);
    }
  }

  endMatch(stateDir);
  const keepOwnerForRoomReturn = left === 'game_returned_room' && presence.owner_pid !== null && !leaveError;
  const owner = keepOwnerForRoomReturn
    ? { pid: presence.owner_pid, stopped: false }
    : await stopOwnerWithCommand(stateDir, 'quit');
  stopStrategyIfRunning();

  const reminder = hubReminder(readCachedGamesPlayed());
  const out: Record<string, any> = cleanObject({
    ok: true,
    command: invokedAs,
    left,
    presence: presence.presence,
    game_result: gameResult,
    queue_result: queueResult,
    room_result: roomResult,
    leave_error: leaveError,
    detection_errors: presence.errors,
    queue_status: presence.queue_status,
    owner_pid: owner.pid ?? presence.owner_pid ?? undefined,
    owner_stopped: owner.stopped,
    hub_reminder: left === 'game_returned_room' || left === 'room' ? undefined : reminder,
    next_step: left === 'game_returned_room'
      ? 'Left the room match. The active stream will reconnect to the familiar room lobby.'
      : left === 'room'
        ? 'Left the familiar room.'
        : reminder ? `Stopped local runtime. ${reminder}` : undefined,
  });
  if (presence.presence === 'in_game' && leaveError?.still_alive) {
    out.message = 'The server rejected leaving the active game because you are still alive; local runtime was stopped only.';
  }
  console.log(JSON.stringify(out, null, 2));
}

async function runEnterRoom(
  source: 'room_create' | 'room_join',
  enter: (client: GameClient) => Promise<any>,
  opts: { force?: boolean; channelUrl?: string; agentType?: string; llmModel?: string },
  targetRoomCode?: string,
): Promise<void> {
  requireStreamTool(source === 'room_create' ? 'ccl game create-room' : 'ccl game join-room <room_code>');
  const profile = new AuthStore().getActive();
  if (!profile) throw new Error(noAccountMessage('gameStart'));
  const events = startEventSession(source);
  const client = GameClient.fromAuth();
  const stateDir = getProfileStateDir(profile);
  const streamGuard = planRoomEntryStreamGuard({
    runningPid: getRunningGameStartPid(stateDir),
    force: opts.force,
    source,
  });
  if (streamGuard.kind === 'already_running') {
    events.append({ type: 'already_running', pid: streamGuard.pid, source, target_room_code: targetRoomCode });
    console.log(JSON.stringify({
      exit_reason: ['already_running'],
      next_step: streamGuard.nextStep,
      events: [{ type: 'already_running', pid: streamGuard.pid }],
      summary: { phase: 'matching', you: { name: profile.agentName }, game: {}, urgent: {}, meeting: null },
    }));
    return;
  }
  let shouldEnter = true;
  if (source === 'room_join' && targetRoomCode) {
    let queue: any | undefined;
    try {
      queue = await client.getQueueStatus('clawclaw');
    } catch (err) {
      events.append({
        type: 'room_join_preflight_failed',
        target_room_code: targetRoomCode,
        error: serializeError(err),
      });
    }
    if (queue) {
      const plan = planRoomJoinAction({
        targetRoomCode,
        queueStatus: roomQueueStatus(queue),
        queueMode: roomQueueMode(queue),
        currentRoomCode: queueRoomCode(queue),
      });
      events.append({
        type: 'room_join_preflight',
        target_room_code: targetRoomCode,
        current_room_code: 'roomCode' in plan ? plan.roomCode : undefined,
        action: plan.kind,
      });
      if (plan.kind === 'resume_current_room') {
        shouldEnter = false;
      } else if (plan.kind === 'leave_then_join') {
        try {
          const leaveResult = await client.leaveRoom();
          events.append({
            type: 'room_left_before_join',
            room_code: plan.roomCode,
            target_room_code: targetRoomCode,
            result: leaveResult,
          });
        } catch (err) {
          events.append({
            type: 'room_leave_before_join_failed',
            room_code: plan.roomCode,
            target_room_code: targetRoomCode,
            error: serializeError(err),
          });
          throw err;
        }
      } else if (plan.kind === 'refuse_room_allocated') {
        const err = new Error('This account is already in a room match allocation; cannot switch rooms automatically.');
        events.append({ type: 'room_join_refused', target_room_code: targetRoomCode, error: serializeError(err) });
        throw err;
      }
    }
  }
  stopStrategyIfRunning();
  try {
    if (shouldEnter) {
      const result = await enter(client);
      const data = unwrapData(result);
      events.append({ type: `${source}_accepted`, ...data });
      if (data?.spectate_url) {
        const event = {
          type: source === 'room_create' ? 'room_created' : 'room_joined',
          source,
          ...data,
        };
        const line = JSON.stringify({
          exit_reason: [event.type],
          next_step: 'Room entered. Share events[0].spectate_url with the user as the room page link. Keep the room stream attached.',
          events: [event],
          summary: {
            phase: 'room_waiting',
            you: { name: profile.agentName, seat: data.seat, is_owner: data.is_owner },
            room: { room_code: data.room_code, status: data.status },
          },
        }) + '\n';
        process.stdout.write(line);
        if (opts.channelUrl) {
          fetch(opts.channelUrl, { method: 'POST', headers: { 'content-type': 'application/json' }, body: line }).catch(() => {});
        }
      }
    } else {
      events.append({ type: `${source}_accepted`, room_code: targetRoomCode, already_in_room: true });
    }
  } catch (err) {
    if (!isAlreadyInRoomError(err)) {
      events.append({ type: `${source}_failed`, error: serializeError(err) });
      throw err;
    }
    if (source === 'room_join') {
      events.append({
        type: `${source}_failed`,
        room_code: targetRoomCode,
        error: serializeError(err),
      });
      throw err;
    }
    events.append({
      type: 'room_already_joined',
      room_code: roomCodeFromAlreadyInRoomError(err),
    });
  }
  await runGameStart({
    force: opts.force,
    channelUrl: opts.channelUrl,
    agentType: opts.agentType,
    llmModel: opts.llmModel ?? '',
  });
}

async function runGameStartRoom(): Promise<void> {
  const authStore = new AuthStore();
  const profile = authStore.getActive();
  if (!profile) throw new Error('Not logged in.');
  const client = GameClient.fromAuth({ authStore });
  const queue = await client.getQueueStatus('clawclaw');
  if (roomQueueStatus(queue) !== 'in_room') {
    console.log(JSON.stringify({
      ok: false,
      error: 'not_in_room',
      queue_status: unwrapData(queue),
      message: 'This account is not currently in a room.',
    }, null, 2));
    process.exitCode = 1;
    return;
  }
  try {
    const store = EventStore.forActiveAccount();
    store.append({
      type: 'room_start_requested',
      room_code: queueRoomCode(queue),
      ts: new Date().toISOString(),
    });
  } catch {}
  let result: any;
  try {
    result = await client.startRoom();
  } catch (err) {
    const failure = roomStartFailureFromError(err, queue);
    if (failure) {
      console.log(JSON.stringify(failure, null, 2));
      process.exitCode = 1;
      return;
    }
    throw err;
  }
  try {
    const store = EventStore.forActiveAccount();
    store.append({ type: 'room_start_accepted', ts: new Date().toISOString(), ...unwrapData(result) });
  } catch {}
  console.log(JSON.stringify({
    ok: true,
    command: 'game start-room',
    result,
  }, null, 2));
}

/**
 * Absolute path to the bundled read-only Monitor diagnostic. Resolved from this
 * module's location — the package ships and runs straight from `src/` (see
 * `bin/clawclaw-cli.mjs`), so `../../scripts` is `<pkg>/scripts` in both the dev
 * tree and an installed package. `scripts/` is listed in package.json `files`.
 */
export const MONITOR_CHECK_SCRIPT = resolve(dirname(fileURLToPath(import.meta.url)), '..', '..', 'scripts', 'check_monitor.cjs');

/**
 * Detect whether the built-in Claude Code Monitor tool is available in this
 * environment, for the `X-Monitor-Status` header on queue join. Runs the bundled
 * diagnostic `scripts/check_monitor.cjs` (exit 0 = available → 'on', anything
 * else = unavailable → 'off'). That script probes the real tool list once via a
 * short `claude --max-turns 0` subprocess and caches the verdict by fingerprint,
 * so the ~4s probe is paid once per (version, config) and later joins hit the
 * cache. stdout is discarded so it never pollutes the NDJSON event stream, and
 * any failure (missing script, crash, timeout) is treated as 'off' — the header
 * must never block matchmaking. `scriptPath` is injectable for testing;
 * production always uses the bundled default.
 */
export function detectMonitorStatus(scriptPath: string = MONITOR_CHECK_SCRIPT): 'on' | 'off' {
  try {
    const result = spawnSync(process.execPath, [scriptPath], {
      stdio: ['ignore', 'ignore', 'ignore'],
      timeout: 10_000,
    });
    return result.status === 0 ? 'on' : 'off';
  } catch {
    return 'off';
  }
}

function requireStreamTool(command: string): void {
  if (process.env.CLAWCLAW_REQUIRE_STREAM_TOOL !== '1' || process.env.CLAWCLAW_STREAMED === '1') return;
  process.stderr.write(
    `In OpenClaw, start the game via the clawclaw_game_start tool - do not exec \`${command}\` directly.\n` +
    'When run raw, the event NDJSON only fills an unconsumed buffer, so you never receive speech_your_turn and stay silent.\n',
  );
  process.exit(2);
}

async function runGameStart(opts: { force?: boolean; channelUrl?: string; competition?: boolean; agentType?: string; llmModel: string }): Promise<void> {
  requireStreamTool('ccl game start');

  // Channel 模式硬兜底:带 --channel-url 时,把真正的长流逻辑 fork 到 detached 后台进程,
  // 父进程立即返回。这样即使 agent 误用 Powershell/前台直接执行,也不会阻塞——事件仍通过
  // --channel-url 的 HTTP POST 推送给 channel,后台进程的 PID 写入 runtime 文件,
  // `ccl game quit` 依旧能正确清理。CLAWCLAW_CHANNEL_DETACHED 防止子进程再次 fork。
  if (opts.channelUrl && process.env.CLAWCLAW_CHANNEL_DETACHED !== '1') {
    // 先做本地登录校验,保证前台调用能立即看到未登录错误(detach 后 stdout 被丢弃)。
    const detachProfile = new AuthStore().getActive();
    if (!detachProfile) throw new Error(noAccountMessage('gameStart'));
    // 已有运行中的 stream 时不重复 fork:detach 的瞬间返回容易诱发 agent 误重试,
    // 在父进程同步拦截顺序重试,避免拉起第二个注定 already_running 的后台进程。
    const existingPid = getRunningGameStartPid(getProfileStateDir(detachProfile));
    if (existingPid && !opts.force) {
      process.stdout.write(JSON.stringify({
        exit_reason: ['already_running'],
        next_step: `已有 ccl game start 后台进程在运行(pid ${existingPid}),事件正通过 channel 推送。不要重复启动;如需重启加 --force,或先 ccl game quit。`,
        events: [{ type: 'already_running', pid: existingPid }],
        summary: { phase: 'matching' },
      }) + '\n');
      return;
    }
    const child = spawn(process.execPath, process.argv.slice(1), {
      detached: true,
      stdio: 'ignore',
      env: { ...process.env, CLAWCLAW_CHANNEL_DETACHED: '1' },
    });
    child.unref();
    process.stdout.write(JSON.stringify({
      exit_reason: ['channel_detached'],
      next_step: 'ccl game start 已在后台启动,事件将通过 channel 实时推送。不要在前台等待此进程;结束本局用 ccl game quit。',
      events: [{ type: 'channel_detached', pid: child.pid }],
      summary: { phase: 'matching' },
    }) + '\n');
    return;
  }

  const authStore = new AuthStore();
  const profile = authStore.getActive();
  if (!profile) throw new Error(noAccountMessage('gameStart'));

  const stateDir = getProfileStateDir(profile);
  const feedPath = join(stateDir, 'feed.json');
  clearCachedGameServerUrl(authStore, profile.agentName);
  const client = GameClient.fromAuth();
  let currentGameMode: RuntimeGameMode | undefined;
  let eventRuntime: EventRuntime | undefined;
  let roomRuntime: RoomLobbyRuntime | undefined;
  let lastEventStopReason: string | null = null;
  let streamAbortController: AbortController | null = null;
  let heartbeatTimer: ReturnType<typeof setInterval> | null = null;
  let ownerControl: OwnerControlServer | null = null;
  let ownerFeed: any = {
    ts: new Date().toISOString(),
    phase: 'matching',
    terminal: false,
    you: { name: profile.agentName },
    game: {},
    urgent: {},
    meeting: null,
    recent_events: [],
  };
  let strategyChild: ChildProcess | null = null;
  let currentStrategy: string | null = null;
  const currentAutomationSummary = (): Record<string, any> | undefined => {
    if (!currentStrategy) return undefined;
    const pid = strategyChild?.pid;
    return { strategy: currentStrategy, running: !!(pid && isPidAlive(pid)) };
  };
  const updateGameMode = (mode: RuntimeGameMode | undefined): void => {
    if (!mode) return;
    currentGameMode = mode;
  };
  const withCurrentGameMode = (payload: Record<string, any>): Record<string, any> => {
    return currentGameMode ? { ...payload, game_mode: currentGameMode } : payload;
  };
  const currentSummary = (): any | null => {
    const runtimeFeed = eventRuntime?.snapshot();
    const roomFeed = roomRuntime?.snapshot();
    const feed = runtimeFeed && runtimeFeed.phase !== 'lobby'
      ? runtimeFeed
      : roomFeed ?? ownerFeed;
    const automation = currentAutomationSummary();
    return summarizeFeed(automation ? { ...feed, automation } : feed);
  };
  let manualExitEmitted = false;
  let ownerExitRequested = false;
  const stopOwnedStrategy = (): void => {
    const child = strategyChild;
    strategyChild = null;
    currentStrategy = null;
    if (child?.pid && isPidAlive(child.pid)) {
      try { child.kill('SIGTERM'); } catch {}
      setTimeout(() => {
        if (child.pid && isPidAlive(child.pid)) {
          try { child.kill('SIGKILL'); } catch {}
        }
      }, 1000).unref();
    }
    eventRuntime?.refreshFeed();
  };
  const onOwnerSignal = (): void => {
    streamAbortController?.abort();
    eventRuntime?.stop('SIGTERM');
    roomRuntime?.stop('SIGTERM');
    stopOwnedStrategy();
    cleanupGameStartRuntime(stateDir, { removeFeed: true, controlPath: ownerControl?.control.path });
    process.exit(130);
  };
  process.on('SIGINT', onOwnerSignal);
  process.on('SIGTERM', onOwnerSignal);
  const emit = (obj: Record<string, any>): void => {
    const line = JSON.stringify(obj) + '\n';
    process.stdout.write(line);
    if (opts.channelUrl) {
      fetch(opts.channelUrl, { method: 'POST', headers: { 'content-type': 'application/json' }, body: line }).catch(() => {});
    }
  };
  const emitLifecycle = (
    reason: string,
    payload: Record<string, any>,
    nextStepOverride?: string,
  ): void => {
    const event = { ...payload, type: reason };
    emit({
      exit_reason: [reason],
      next_step: nextStepOverride ?? nextStepFor(reason),
      events: [event],
      summary: currentSummary(),
    });
  };
  const emitOwnerExit = (kind: 'stop' | 'quit' | 'leave', command: string): void => {
    if (manualExitEmitted) return;
    manualExitEmitted = true;
    const eventType = kind === 'leave' ? 'stop' : kind;
    emit({
      exit_reason: [eventType],
      next_step: `Received ${command}. The current ccl game start process is exiting now.`,
      events: [{ type: eventType, command }],
      summary: { phase: 'stopped' },
    });
  };
  const requestOwnerExit = (kind: 'stop' | 'quit' | 'leave', command: string): void => {
    ownerExitRequested = true;
    emitOwnerExit(kind, command);
    streamAbortController?.abort();
    eventRuntime?.stop('manual');
    roomRuntime?.stop('manual');
    stopOwnedStrategy();
  };
  const emitMatchEvent = (evt: Record<string, any>): void => {
    try {
      const store = EventStore.forActiveAccount();
      store.append({ ts: new Date().toISOString(), ...evt });
    } catch {}
  };
  const ensureEventRuntime = async (): Promise<void> => {
    if (eventRuntime) return;
    lastEventStopReason = null;
    eventRuntime = new EventRuntime({
      authStore,
      getAutomation: currentAutomationSummary,
      getGameMode: () => currentGameMode,
      onStop: (stop) => {
        lastEventStopReason = stop.reason;
        if (stop.reason === 'game_over' || stop.reason === 'user_left_game') return;
        streamAbortController?.abort();
      },
    });
    await eventRuntime.start();
  };
  const streamGame = async (streamOpts: {
    emitGameStart?: boolean;
    finalDrainOnAbort?: boolean | (() => boolean);
    finalDrainTimeoutMs?: number;
  } = {}): Promise<void> => {
    const sessionPath = EventStore.latestSessionPath();
    const ctrl = new AbortController();
    streamAbortController = ctrl;
    const onSignal = (): void => {
      ctrl.abort();
      eventRuntime?.stop('SIGTERM');
    };
    process.on('SIGINT', onSignal);
    process.on('SIGTERM', onSignal);
    try {
      await runStreaming({
        feedPath,
        sessionPath,
        getSessionPath: () => EventStore.latestSessionPath(),
        stdout: (s) => { process.stdout.write(s); if (opts.channelUrl) { fetch(opts.channelUrl, { method: 'POST', headers: { 'content-type': 'application/json' }, body: s }).catch(() => {}); } },
        signal: ctrl.signal,
        skipFeedWait: true,
        readSummary: currentSummary,
        skipBacklogTypes: ['match_waiting', 'match_timeout'],
        emitGameStart: streamOpts.emitGameStart ?? true,
        finalDrainOnAbort: streamOpts.finalDrainOnAbort,
        finalDrainTimeoutMs: streamOpts.finalDrainTimeoutMs,
        hubReminder: hubReminder(readCachedGamesPlayed()),
      });
    } catch (err: any) {
      process.stdout.write(buildErrorLine(err));
      process.exitCode = 1;
    } finally {
      process.off('SIGINT', onSignal);
      process.off('SIGTERM', onSignal);
      streamAbortController = null;
    }
  };
  const handleAllocated = async (
    queue: any,
    preserveStrategy: boolean,
    sessionSource?: string,
  ): Promise<void> => {
    let currentQueue = queue;
    updateGameMode(isRoomAllocatedQueueStatus(currentQueue) ? 'room' : queueMode(currentQueue));
    try {
      const refreshedQueue = await client.getQueueStatus('clawclaw');
      if (queueStatus(refreshedQueue) === 'allocated') {
        currentQueue = refreshedQueue;
        updateGameMode(isRoomAllocatedQueueStatus(currentQueue) ? 'room' : queueMode(currentQueue));
      }
    } catch {
      // Keep the original allocation; role polling below will report failures.
    }
    const finalState = readMatchState(stateDir);
    const waitedSecs = finalState ? getWaitedSecs(finalState) : 0;
    endMatch(stateDir);
    eventRuntime?.stop('manual');
    eventRuntime = undefined;
    startEventSession(sessionSource ?? (isRoomAllocatedQueueStatus(currentQueue) ? 'room_game' : 'game_start_allocated'), {
      game_id: nonEmptyString(currentQueue?.game_id) ?? nonEmptyString(currentQueue?.data?.game_id),
    });
    const roleResult = await waitForRoleInfo(client);
    // Supplement game_id from queue response when /game/current doesn't carry it yet.
    const queueGameId = nonEmptyString(currentQueue?.game_id) ?? nonEmptyString(currentQueue?.data?.game_id);
    if (roleResult.timedOut || !roleResult.role) {
      ownerFeed = {
        ...ownerFeed,
        ts: new Date().toISOString(),
        phase: 'allocated',
        allocation: {
          queue: currentQueue,
          waited_secs: waitedSecs,
          game_id: queueGameId,
          errors: roleResult.errors,
          role_timeout_ms: 30_000,
        },
      };
      emitLifecycle('role_timeout', {
        queue: currentQueue,
        waited_secs: waitedSecs,
        game_id: queueGameId,
        errors: roleResult.errors,
        role_timeout_ms: 30_000,
      }, 'Timed out waiting for /api/v1/game/role_info. The stream is exiting; retry `ccl game start` after the GameServer is ready.');
      process.exitCode = 1;
      return;
    }
    const context = await fetchGameContextWithRole(client, roleResult.role);
    let identity = gameStrategyIdentity(context.state, context.role);
    if (!identity.gameId && queueGameId) {
      identity.gameId = queueGameId;
    }
    let strategyInfo: { strategy: string; pid: number | undefined } | null = null;
    if (identity.alive !== false) {
      stopOwnedStrategy();
      const started = autoStartStrategy(context.role, context.state, identity.gameId);
      if (started) {
        strategyChild = started.child;
        currentStrategy = started.strategy;
        strategyInfo = { strategy: started.strategy, pid: started.pid };
      }
    }
    const role = unwrapData(context.role);
    ownerFeed = {
      ...ownerFeed,
      ts: new Date().toISOString(),
      phase: 'allocated',
      you: {
        ...ownerFeed.you,
        role: nonEmptyString(role?.role) ?? identity.role,
        role_display: nonEmptyString(role?.role_display_name) ?? nonEmptyString(role?.role_display),
        faction: nonEmptyString(role?.faction),
        alive: identity.alive,
      },
      game: {
        ...ownerFeed.game,
        game_id: identity.gameId,
      },
    };
    const allocationPayload = {
      queue: currentQueue,
      waited_secs: waitedSecs,
      game_id: identity.gameId || undefined,
      ...context,
      ...(strategyInfo ? { default_strategy: strategyInfo } : {}),
    };
    ownerFeed = { ...ownerFeed, allocation: allocationPayload };
    await ensureEventRuntime();
    await streamGame();
  };
  const roomReturnSourceFor = (endedReason: string | null | undefined): string => {
    return endedReason === 'user_left_game' ? 'room_game_left_return_room' : 'room_game_over_return_room';
  };

  const prepareRoomLobbyReturn = async (endedReason: string | null | undefined): Promise<string | null> => {
    eventRuntime?.stop('manual');
    eventRuntime = undefined;
    stopOwnedStrategy();
    clearCachedGameServerUrl(authStore, profile.agentName);
    if (ownerExitRequested) return null;

    let queueAfterGame: any;
    try {
      queueAfterGame = await client.getQueueStatus('clawclaw');
      updateGameMode(queueMode(queueAfterGame));
    } catch (err) {
      emitLifecycle('room_lobby_reconnect_failed', {
        error: err instanceof Error ? err.message : String(err),
        message: 'Room match ended, but Lobby queue status could not be confirmed.',
      }, 'Room match ended. Launch `ccl game start` to reattach, or ask the user whether to leave the room.');
      return null;
    }

    if (!hasRoomQueueContext(queueAfterGame)) {
      emitLifecycle('room_lobby_missing', {
        queue: queueAfterGame,
        message: 'Room match ended, but this account is no longer in a familiar room.',
      }, 'Room match ended and no room lobby was found. Do not auto-start another match; ask the user what to do next.');
      return null;
    }

    emitMatchEvent({
      type: 'room_returning_to_lobby',
      reason: endedReason ?? 'game_over',
      queue: queueAfterGame,
      message: 'Room match ended; reconnecting to Lobby agent_ws for the room snapshot.',
    });
    return roomReturnSourceFor(endedReason);
  };

  const streamRoomLobby = async (source: string): Promise<void> => {
    let roomSource = source;
    while (!ownerExitRequested) {
      startEventSession(roomSource);
      const roomStartedRef: { value: RoomGameStarted | null } = { value: null };
      let roomStopReason: string | null = null;
      ownerFeed = {
        ...ownerFeed,
        ts: new Date().toISOString(),
        phase: 'room_waiting',
      };
      roomRuntime = new RoomLobbyRuntime({
        authStore,
        onGameStarted: (event) => {
          roomStartedRef.value = event;
          streamAbortController?.abort();
        },
        onStop: (reason) => {
          roomStopReason = reason;
          if (reason === 'self_left') streamAbortController?.abort();
        },
      });
      await roomRuntime.start();
      if (!roomStartedRef.value) {
        await streamGame({
          emitGameStart: false,
          finalDrainOnAbort: () => !!roomStartedRef.value,
          finalDrainTimeoutMs: 500,
        });
      }
      if (!roomStartedRef.value && ownerExitRequested) return;
      if (!roomStartedRef.value && roomStopReason === 'self_left') {
        emitLifecycle('room_self_left', {
          message: ROOM_SELF_LEFT_STREAM_MESSAGE,
        }, ROOM_SELF_LEFT_MESSAGE);
        return;
      }
      if (!roomStartedRef.value || ownerExitRequested) return;

      const started = roomStartedRef.value;
      roomRuntime.stop('game_started');
      roomRuntime = undefined;
      applyRoomGameServer(client, started);
      await handleAllocated({
        status: 'allocated',
        game_id: started.game_id,
        game_server_name: started.game_server_name,
        ws_url: started.ws_url,
      }, true);

      const endedReason = lastEventStopReason ?? eventRuntime?.snapshot()?.phase;
      const nextRoomSource = await prepareRoomLobbyReturn(endedReason);
      if (!nextRoomSource) return;
      roomSource = nextRoomSource;
    }
  };
  const streamRoomAllocated = async (queue: any, source: string): Promise<void> => {
    await handleAllocated(queue, true, source);
    const endedReason = lastEventStopReason ?? eventRuntime?.snapshot()?.phase;
    const nextRoomSource = await prepareRoomLobbyReturn(endedReason);
    if (!nextRoomSource) return;
    await streamRoomLobby(nextRoomSource);
  };
  const pollQueue = async (preserveStrategy: boolean): Promise<void> => {
    const intervalMs = 2000;
    const QUEUE_POLL_HEARTBEAT_MS = 60_000;
    const MAX_CONSECUTIVE_FAILURES = 3;
    let lastHeartbeat = Date.now();
    let consecutiveFailures = 0;

    while (!ownerExitRequested) {
      let queue: any;
      try {
        queue = await client.getQueueStatus('clawclaw');
        updateGameMode(queueMode(queue));
        consecutiveFailures = 0;
      } catch (err: any) {
        consecutiveFailures++;
        const msg = err?.message ?? String(err);
        if (consecutiveFailures >= MAX_CONSECUTIVE_FAILURES) {
          emitLifecycle('error', {
            error: msg,
            consecutive_failures: consecutiveFailures,
            message: `Queue status polling failed ${consecutiveFailures} times consecutively.`,
          }, 'Queue polling has failed repeatedly. Check network, then launch a fresh `ccl game start` to retry.');
          return;
        }
        emitLifecycle('poll_error', {
          error: msg,
          consecutive_failures: consecutiveFailures,
          message: `Queue poll failed (${consecutiveFailures}/${MAX_CONSECUTIVE_FAILURES}). Retrying...`,
        }, 'Temporary polling failure. The stream will keep retrying - stay attached.');
        await sleep(intervalMs);
        continue;
      }

      if (queueStatus(queue) === 'allocated') {
        if (isRoomAllocatedQueueStatus(queue)) {
          await streamRoomAllocated(queue, 'game_start_room_allocated_from_queue');
          return;
        }
        await handleAllocated(queue, preserveStrategy);
        return;
      }
      if (queueStatus(queue) === 'not_in_queue') {
        endMatch(stateDir);
        emitLifecycle('not_in_queue', { queue, message: 'Left or dropped from queue.' },
          'No longer in matchmaking queue. The stream will exit; launch a fresh `ccl game start` to retry if the user wants to continue.');
        return;
      }

      const cur = readMatchState(stateDir);
      if (cur && shouldEmitWaiting(cur)) {
        const waitedSecs = getWaitedSecs(cur);
        emitMatchEvent(withCurrentGameMode({ type: 'match_waiting', waited_secs: waitedSecs }));
        emitLifecycle('match_waiting', withCurrentGameMode({ waited_secs: waitedSecs }));
        markWaitingEmitted(stateDir);
        lastHeartbeat = Date.now();
      }
      if (cur && hasMatchTimedOut(cur)) {
        const waitedSecs = getWaitedSecs(cur);
        emitMatchEvent(withCurrentGameMode({ type: 'match_timeout', waited_secs: waitedSecs }));
        emitLifecycle('match_timeout', withCurrentGameMode({
          waited_secs: waitedSecs,
          queue,
          message: `No match after ${waitedSecs}s.`,
        }));
        return;
      }

      if (Date.now() - lastHeartbeat >= QUEUE_POLL_HEARTBEAT_MS) {
        lastHeartbeat = Date.now();
        const waitedSecs = cur ? getWaitedSecs(cur) : 0;
        emitLifecycle('heartbeat', withCurrentGameMode({ waited_secs: waitedSecs }),
          'Stream is alive; still waiting for match allocation. Keep chatting with the user.');
      }

      await sleep(intervalMs);
    }
  };
  const resumeQueue = async (source: string): Promise<void> => {
    ensureEventSession(source);
    if (!readMatchState(stateDir)) startMatch(stateDir);
    await ensureEventRuntime();
    await pollQueue(true);
  };
  const recoverAlreadyInGame = async (): Promise<boolean> => {
    let queue: any;
    try {
      queue = await client.getQueueStatus('clawclaw');
      updateGameMode(queueMode(queue));
    } catch {
      return false;
    }
    const status = queueStatus(queue);
    if (status === 'allocated') {
      if (isRoomAllocatedQueueStatus(queue)) {
        await streamRoomAllocated(queue, 'game_start_already_in_room_game');
      } else {
        await handleAllocated(queue, true);
      }
      return true;
    }
    if (status === 'queued' || status === 'already_in_queue') {
      await resumeQueue('game_start_already_in_game_queue');
      return true;
    }
    return false;
  };

  const runningGameStartPid = getRunningGameStartPid(stateDir);
  if (runningGameStartPid) {
    if (opts.force) {
      terminateProcessTree(runningGameStartPid, 'SIGKILL');
      try { unlinkSync(gameStartRuntimePath(stateDir)); } catch {}
      try { unlinkSync(feedPath); } catch {}
    } else {
      emitLifecycle('already_running', { pid: runningGameStartPid },
        `A ccl game start stream is already running (pid ${runningGameStartPid}). To replace it, re-run with --force or stop it manually: taskkill /F /PID ${runningGameStartPid}`);
      return;
    }
  }
  let initialQueue: any;
  try {
    initialQueue = await client.getQueueStatus('clawclaw');
    updateGameMode(queueMode(initialQueue));
  } catch {}
  const plan = planGameStartAction({
    gameStartPid: null,
    hasMatchState: readMatchState(stateDir) !== null,
    queueStatus: queueStatus(initialQueue),
    queueMode: roomQueueMode(initialQueue),
  });

  ownerFeed = {
    ...ownerFeed,
    ts: new Date().toISOString(),
    phase: (plan.kind === 'resume_allocated' || plan.kind === 'resume_room_allocated') ? 'allocated' : 'matching',
  };
  if (plan.kind !== 'fresh_start' && !queueMode(initialQueue)) currentGameMode = undefined;
  ownerControl = await startOwnerControlServer(stateDir, async (request) => {
    if (request.type === 'snapshot') {
      return { ok: true, type: 'snapshot', summary: currentSummary() };
    }
    if (request.type === 'stop') {
      requestOwnerExit('stop', 'ccl game stop');
      return { ok: true, type: 'stop' };
    }
    if (request.type === 'quit') {
      requestOwnerExit('quit', 'ccl game quit');
      return { ok: true, type: 'quit' };
    }
    if (request.type === 'leave') {
      requestOwnerExit('leave', 'ccl game leave');
      return { ok: true, type: 'leave' };
    }
    if (request.type === 'stop_strategy') {
      stopOwnedStrategy();
      return { ok: true, type: 'stop_strategy' };
    }
    if (request.type === 'switch_strategy') {
      if (!request.strategy) return { ok: false, error: 'missing_strategy' };
      stopOwnedStrategy();
      const child = spawnStrategyLoop(request.strategy, request.args, {
        source: 'manual',
        detached: false,
        writeRuntimeFiles: false,
      });
      strategyChild = child;
      currentStrategy = request.strategy;
      eventRuntime?.refreshFeed();
      return { ok: true, type: 'switch_strategy', strategy: request.strategy, pid: child.pid };
    }
    return { ok: false, error: 'unsupported_owner_control_request' };
  });
  writeGameStartRuntime(stateDir, plan.kind, undefined, {
    control: ownerControl.control,
    token: ownerControl.token,
  });
  heartbeatTimer = startGameStartHeartbeat(stateDir, plan.kind);
  try {
    if (plan.kind === 'resume_queue') {
      await resumeQueue('game_start_resume_queue');
      return;
    }

    if (plan.kind === 'resume_allocated') {
      await handleAllocated(initialQueue, true);
      return;
    }

    if (plan.kind === 'resume_room_allocated') {
      await streamRoomAllocated(initialQueue, 'game_start_resume_room_allocated');
      return;
    }

    if (plan.kind === 'resume_room') {
      await streamRoomLobby('game_start_resume_room');
      return;
    }

    let joinResult: any;
    try {
      // The Monitor status is only meaningful for Claude Code hosts; for other
      // agent platforms we leave the header off entirely.
      const monitorStatus = opts.agentType === 'claude-code' ? detectMonitorStatus() : undefined;
      joinResult = await client.joinQueue({
        gameType: 'clawclaw',
        mode: opts.competition ? 'competition' : 'match',
        agentType: opts.agentType,
        monitorStatus,
        llmModel: opts.llmModel,
      });
      updateGameMode(queueMode(joinResult));
    } catch (err) {
      if (isAlreadyInRoomError(err)) {
        await streamRoomLobby('game_start_already_in_room');
        return;
      }
      if (isAlreadyInGameError(err) && await recoverAlreadyInGame()) return;
      if (opts.competition) {
        const code = apiErrorCode(err);
        let competition: Record<string, any> = { status: 'unavailable', active: null, is_open: null };
        try {
          competition = normalizeCompetitionStatusResponse(await client.getCompetitionStatus());
        } catch (statusErr) {
          competition = {
            status: 'unavailable',
            active: null,
            is_open: null,
            error: serializeCompetitionError(statusErr),
          };
        }
        emit({
          exit_reason: [competitionExitReason(code)],
          next_step: competitionNextStep(code),
          events: [{
            type: 'game_start_failed',
            game_mode: 'competition',
            exit_reason: competitionExitReason(code),
            error: {
              code: code ?? 'REQUEST_FAILED',
              message: apiErrorMessage(err),
            },
            competition,
          }],
          summary: currentSummary(),
        });
        process.exitCode = 1;
        return;
      }
      throw err;
    }
    const joinedStatus = queueStatus(joinResult);
    if (joinedStatus === 'in_room') {
      await streamRoomLobby('game_start_joined_room');
      return;
    }
    if (joinedStatus === 'already_in_queue') {
      await resumeQueue('game_start_already_in_queue');
      return;
    }
    if (joinedStatus && joinedStatus !== 'queued') {
      emitLifecycle('error', { queue: joinResult, message: `Unexpected queue status: ${joinedStatus}.` },
        'Queue join did not enter the clawclaw queue. Check queue status before launching another `ccl game start`.');
      return;
    }

    let joinedQueue = joinResult;
    try {
      joinedQueue = await client.getQueueStatus('clawclaw');
      updateGameMode(queueMode(joinedQueue));
    } catch {}

    stopOwnedStrategy();

    const events = startEventSession('game_start');
    emitLifecycle('joined', withCurrentGameMode(joinedLifecyclePayload(joinResult, joinedQueue)),
      'Join request acknowledged. Spectate URL is in `events[0].url`; share it with the user. Game runtime is attached.');
    startMatch(stateDir);
    await ensureEventRuntime();
    await pollQueue(false);
  } finally {
    if (heartbeatTimer) clearInterval(heartbeatTimer);
    process.off('SIGINT', onOwnerSignal);
    process.off('SIGTERM', onOwnerSignal);
    eventRuntime?.stop('manual');
    roomRuntime?.stop('manual');
    stopOwnedStrategy();
    if (ownerControl) await ownerControl.close();
    cleanupGameStartRuntime(stateDir, { removeFeed: true, controlPath: ownerControl?.control.path });
  }
}

export function createGameCommand(): Command {
  const game = new Command('game');
  game.description('Game matchmaking & session');

  game
    .command('join', { hidden: true })
    .alias('j')
    .description('Join matchmaking queue')
    .option('--agent-type <platform>', 'the agent platform you are running on (e.g. claude-code, openclaw, hermes); reported to the server via the X-Agent-Type header on queue join')
    .option('--llm-model <model>', 'the exact reasoning/inference model ID currently powering you, the lobster (龙虾) agent, this match. Report the canonical model identifier you are actually running as (e.g. claude-opus-4-8, claude-sonnet-4-6, claude-haiku-4-5-20251001, gpt-5), NOT a friendly/marketing name like "Claude", "Opus", or "GPT". Sent verbatim to the server via the X-LLM-Model header on queue join')
    .action(async (opts: { agentType?: string; llmModel?: string }) => {
      const authStore = new AuthStore();
      const profile = authStore.getActive();
      if (!profile) throw new Error('Not logged in.');
      stopStrategyIfRunning();

      const client = GameClient.fromAuth();
      // Monitor status is only meaningful for Claude Code hosts; leave the header
      // off for other platforms (mirrors `game start`).
      const monitorStatus = opts.agentType === 'claude-code' ? detectMonitorStatus() : undefined;
      const result = await client.joinQueue({ gameType: 'clawclaw', agentType: opts.agentType, monitorStatus, llmModel: opts.llmModel });
      const events = startEventSession('game_join');
      console.log(JSON.stringify(result, null, 2));
      const stateDir = getProfileStateDir(profile);
      // Reset match-state: a fresh queue session starts here so `ccl game queue`
      // can compute `waited_secs` from a known anchor and emit the
      // match_waiting / match_timeout synthetic events.
      startMatch(stateDir);
    });

  game
    .command('start')
    .alias('s')
    .description('Start or resume the owner game runtime, then stream events as NDJSON until game_over. Pass --force to replace an orphaned game-start runtime.')
    .requiredOption('--agent-type <platform>', 'the agent platform you are running on (e.g. claude-code, openclaw, hermes); reported to the server via the X-Agent-Type header on queue join')
    .option('--llm-model <model>', 'the exact reasoning/inference model ID currently powering you, the lobster (龙虾) agent, this match. Report the canonical model identifier you are actually running as (e.g. claude-opus-4-8, claude-sonnet-4-6, claude-haiku-4-5-20251001, gpt-5), NOT a friendly/marketing name like "Claude", "Opus", or "GPT". Sent verbatim to the server via the X-LLM-Model header on queue join')
    .option('--force', 'force restart: kill any lingering game-start stream process and start fresh')
    .addOption(new Option('--competition', 'enter competition matchmaking instead of normal matchmaking').hideHelp())
    .option('--channel-url <url>', 'forward each NDJSON line via HTTP POST to this URL')
    .action(runGameStart);

  game
    .command('create-room', { hidden: true })
    .description('Create a familiar room, then attach the room lobby stream as NDJSON.')
    .option('--force', 'force restart: kill any lingering game-start stream process and start fresh')
    .option('--channel-url <url>', 'forward each NDJSON line via HTTP POST to this URL')
    .option('--agent-type <platform>', 'the agent platform you are running on (e.g. claude-code, openclaw, hermes)')
    .option('--llm-model <model>', 'the model the lobster side uses; stored only if the flow later enters matchmaking')
    .action(async (opts: { force?: boolean; channelUrl?: string; agentType?: string; llmModel?: string }) => {
      await runEnterRoom('room_create', (client) => client.createRoom(), opts);
    });

  game
    .command('join-room', { hidden: true })
    .description('Join a familiar room, then attach the room lobby stream as NDJSON.')
    .argument('<room_code>', 'six digit room code')
    .option('--force', 'force restart: kill any lingering game-start stream process and start fresh')
    .option('--channel-url <url>', 'forward each NDJSON line via HTTP POST to this URL')
    .option('--agent-type <platform>', 'the agent platform you are running on (e.g. claude-code, openclaw, hermes)')
    .option('--llm-model <model>', 'the model the lobster side uses; stored only if the flow later enters matchmaking')
    .action(async (roomCode: string, opts: { force?: boolean; channelUrl?: string; agentType?: string; llmModel?: string }) => {
      await runEnterRoom('room_join', (client) => client.joinRoom(roomCode), opts, roomCode);
    });

  game
    .command('start-room', { hidden: true })
    .description('Manually start the current familiar room if this agent is the room owner.')
    .action(runGameStartRoom);

  game
    .command('queue', { hidden: true })
    .alias('q')
    .description('Wait for matchmaking allocation. Always run as a background bash task (run_in_background: true) — never block on it. Returns when allocated, queue is missing, or timeout (default 30s).')
    .option('-w, --wait', '(no-op, kept for backwards compatibility — wait mode is now the default)')
    .option('--interval <secs>', 'Polling interval', '2')
    .option('--timeout <secs>', 'Max seconds to wait, 0 means forever', String(DEFAULT_QUEUE_WAIT_TIMEOUT_SECS))
    .action(async (opts) => {
      const authStore = new AuthStore();
      const profile = authStore.getActive();
      if (!profile) throw new Error('Not logged in.');
      const stateDir = getProfileStateDir(profile);
      const client = GameClient.fromAuth();

      const intervalMs = positiveNumber(opts.interval, 2) * 1000;
      const timeoutSecs = Number(opts.timeout);
      const deadline = Number.isFinite(timeoutSecs) && timeoutSecs > 0
        ? Date.now() + timeoutSecs * 1000
        : 0;

      // Synthetic matchmaking events feed the game start stream so the agent can
      // observe match progress through the same channel as in-game events.
      // Concurrency note: the agent typically keeps a 30s `ccl game queue`
      // background chain running, so two queue invocations may observe the
      // same `allocated` server state. That is
      // intentionally fine — stream dedups via `eventKey` (type+tick+actor),
      // so consumers only see one notification.
      const emitMatchEvent = (evt: Record<string, any>): void => {
        try {
          const store = EventStore.forActiveAccount();
          store.append({ ts: new Date().toISOString(), ...evt });
        } catch {
          // No active session (user ran queue without join) — skip silently;
          // queue's own JSON return still informs the caller.
        }
      };

      // On entry: emit match_waiting if state exists and heartbeat is due.
      const initialState = readMatchState(stateDir);
      if (initialState && shouldEmitWaiting(initialState)) {
        const waitedSecs = getWaitedSecs(initialState);
        emitMatchEvent({ type: 'match_waiting', waited_secs: waitedSecs });
        markWaitingEmitted(stateDir);
      }

      while (true) {
        const queue = await client.getQueueStatus('clawclaw');
        if (queueStatus(queue) === 'allocated') {
          const finalState = readMatchState(stateDir);
          const waitedSecs = finalState ? getWaitedSecs(finalState) : 0;
          endMatch(stateDir);
          const context = await fetchInitialGameContext(client);
          console.log(JSON.stringify({
            status: 'active_game',
            queue,
            waited_secs: waitedSecs,
            ...context,
            next_step: 'Game allocated. Launch `ccl game start` to attach the owner stream if it is not already running, then narrate the role / map / opening plan to the user.',
          }, null, 2));
          return;
        }
        if (queueStatus(queue) === 'not_in_queue') {
          // Queue session is gone (user manually left or server expired it):
          // clean the match-state so next `game join` starts fresh.
          endMatch(stateDir);
          console.log(JSON.stringify({
            status: 'not_in_queue',
            queue,
            message: 'You are not in the matchmaking queue. Did you forget to run `ccl game join` first?',
            next_step: 'Run `ccl game join` to re-enter the queue, then launch the next `ccl game queue` background task.',
          }, null, 2));
          return;
        }
        if (deadline > 0 && Date.now() >= deadline) {
          const cur = readMatchState(stateDir);
          const waitedSecs = cur ? getWaitedSecs(cur) : timeoutSecs;
          // Total-wait timeout (>=10 min by default) gets its own synthetic
          // event so the agent can prompt the user to keep waiting / leave /
          // retry later. Single 30s queue-attempt timeouts do NOT emit anything
          // — they are an internal polling boundary, not a user-visible event.
          if (cur && hasMatchTimedOut(cur)) {
            emitMatchEvent({ type: 'match_timeout', waited_secs: waitedSecs });
          }
          console.log(JSON.stringify({
            status: 'timeout',
            waited_secs: waitedSecs,
            queue,
            message: `No match after ${timeoutSecs} seconds (total wait ${waitedSecs}s).`,
            next_step: 'ZERO-GAP PROTOCOL: (1) Launch the next `ccl game queue` background task IMMEDIATELY. (2) While it runs, chat with the user — share strategy, persona, banter — never go silent.',
          }, null, 2));
          return;
        }
        await sleep(intervalMs);
      }
    });

  game
    .command('leave', { hidden: true })
    .alias('l')
    .description('Deprecated alias for quit')
    .action(async () => runGameQuit('game leave'));


  game
    .command('stop', { hidden: true })
    .alias('x')
    .description('Stop local game runtime')
    .action(async () => {
      const authStore = new AuthStore();
      const profile = authStore.getActive();
      if (!profile) throw new Error('Not logged in.');
      const stateDir = getProfileStateDir(profile);
      const owner = await stopOwnerIfRunning(stateDir);
      stopStrategyIfRunning();
      if (!owner.stopped) {
        console.log(JSON.stringify({ message: 'No game runtime running.' }, null, 2));
        return;
      }
      console.log(JSON.stringify({
        message: 'Game runtime stopped.',
        ...(owner.pid ? { owner_pid: owner.pid } : {}),
      }, null, 2));
    });

  game
    .command('quit')
    .description('Leave active game and stop local runtime')
    .action(async () => runGameQuit('game quit'));

  game
    .command('map', { hidden: true })
    .alias('m')
    .description('Show game map')
    .option('--ascii', 'Include packaged ASCII topology map')
    .action(async (opts) => {
      const client = GameClient.fromAuth();
      await client.discoverGameServer();
      const result = await client.getMap();
      console.log(JSON.stringify(summarizeGameMap(result, { ascii: opts.ascii === true }), null, 2));
    });

  game
    .command('tasks', { hidden: true })
    .alias('t')
    .description('Show my tasks')
    .action(async () => {
      const client = GameClient.fromAuth();
      await client.discoverGameServer();
      const result = await client.getMap();
      console.log(JSON.stringify(result?.your_tasks ?? [], null, 2));
    });

  game
    .command('role', { hidden: true })
    .alias('r')
    .description('Show my role info')
    .action(async () => {
      const client = GameClient.fromAuth();
      const result = await client.getRoleInfo();
      console.log(JSON.stringify(result, null, 2));
    });

  game
    .command('watch')
    .alias('w')
    .description('Get spectating URL')
    .action(() => {
      const authStore = new AuthStore();
      const profile = authStore.getActive();
      if (!profile) throw new Error('Not logged in.');
      const url = lobbyWatchUrl(profile);
      console.log(JSON.stringify({ url, message: 'Open in browser to spectate.' }, null, 2));
    });

  // ── Adapter metadata: queue blocks up to --timeout secs (default 30); cap L2 spawn at 60s ──
  setMeta(game.commands.find((c) => c.name() === 'queue')!, { longRunning: true, timeoutMs: 60_000 });
  setMeta(game.commands.find((c) => c.name() === 'start')!, { longRunning: true, timeoutMs: 1_800_000 });
  setMeta(game.commands.find((c) => c.name() === 'create-room')!, { longRunning: true, timeoutMs: 1_800_000 });
  setMeta(game.commands.find((c) => c.name() === 'join-room')!, { longRunning: true, timeoutMs: 1_800_000 });

  return game;
}
