import { Command, Option } from 'commander';
import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from 'fs';
import { join } from 'path';
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 {
  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';

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);
}

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 data?.ok === true || data?.message === 'left_game';
}

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_queue' }
  | { kind: 'resume_allocated' }
  | { kind: 'fresh_start' };

type GameStartPlanKind = GameStartPlan['kind'];

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

  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' };
}

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' | '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;
  try {
    await client.discoverGameServer();
    gameState = await client.getGameState();
  } catch (err) {
    errors.push({ source: 'game_current', error: serializeError(err) });
  }
  if (gameState) {
    return {
      presence: 'in_game',
      game_state: gameState,
      owner_pid: ownerPid,
      ...(errors.length ? { errors } : {}),
    };
  }

  let queue: any;
  try {
    queue = await client.getQueueStatus('clawclaw');
  } catch (err) {
    errors.push({ source: 'queue_status', error: serializeError(err) });
  }
  const status = queueStatus(queue);
  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 = EventStore.createSessionForActiveAccount();
  events.append({ type: 'session_started', source });
  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;
}

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;
}

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

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): Promise<{
  state: any | null;
  role: any | null;
  map: any | null;
  tasks: any[] | null;
  errors?: Array<{ command: string; error: string }>;
}> {
  await sleep(500);
  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;
}

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: string | undefined = roleData?.data?.role ?? roleData?.role;
  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' | 'queue' | 'local_runtime' | 'none' = presence.owner_pid ? 'local_runtime' : 'none';
  let gameResult: any;
  let queueResult: any;
  let leaveError: Record<string, any> | undefined;

  if (presence.presence === 'in_game') {
    try {
      gameResult = await client.leaveGame();
      if (isLeaveGameSuccess(gameResult)) clearCachedGameServerUrl(authStore, profile.agentName);
      left = 'game';
    } catch (err) {
      leaveError = {
        ...serializeError(err),
        still_alive: isStillAliveError(err),
      };
      left = 'local_runtime';
    }
  } else if (presence.presence === 'matching') {
    try {
      queueResult = await client.leaveQueue('clawclaw');
      left = 'queue';
    } catch (err) {
      leaveError = serializeError(err);
    }
  }

  endMatch(stateDir);
  const owner = 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,
    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: reminder,
    next_step: 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 runGameStart(opts: { force?: boolean; channelUrl?: string; competition?: boolean; agentType?: string }): Promise<void> {
  if (process.env.CLAWCLAW_REQUIRE_STREAM_TOOL === '1' && process.env.CLAWCLAW_STREAMED !== '1') {
    process.stderr.write(
      'In OpenClaw, start the game via the clawclaw_game_start tool - do not exec `ccl game start` 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);
  }

  // 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: QueueMode | undefined;
  let eventRuntime: EventRuntime | undefined;
  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: QueueMode | 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 feed = runtimeFeed && runtimeFeed.phase !== 'lobby' ? runtimeFeed : 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');
    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');
    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;
    eventRuntime = new EventRuntime({
      authStore,
      getAutomation: currentAutomationSummary,
      getGameMode: () => currentGameMode,
      onStop: (stop) => {
        if (stop.reason === 'game_over' || stop.reason === 'user_left_game') return;
        streamAbortController?.abort();
      },
    });
    await eventRuntime.start();
  };
  const streamGame = async (): 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: true,
        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): Promise<void> => {
    updateGameMode(queueMode(queue));
    const finalState = readMatchState(stateDir);
    const waitedSecs = finalState ? getWaitedSecs(finalState) : 0;
    endMatch(stateDir);
    const context = await fetchInitialGameContext(client);
    const identity = gameStrategyIdentity(context.state, context.role);
    // Supplement game_id from queue response when /game/current doesn't carry it yet.
    const queueGameId = nonEmptyString(queue?.game_id) ?? nonEmptyString(queue?.data?.game_id);
    if (!identity.gameId && queueGameId) {
      identity.gameId = queueGameId;
    }
    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,
      },
    };
    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 allocationPayload = {
      queue,
      waited_secs: waitedSecs,
      game_id: identity.gameId || undefined,
      ...context,
      ...(strategyInfo ? { default_strategy: strategyInfo } : {}),
    };
    ownerFeed = { ...ownerFeed, allocation: allocationPayload };
    await ensureEventRuntime();
    await streamGame();
  };
  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') {
        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') {
      ensureEventSession('game_start_already_in_game');
      await ensureEventRuntime();
      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),
  });

  ownerFeed = {
    ...ownerFeed,
    ts: new Date().toISOString(),
    phase: plan.kind === 'resume_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') {
      ensureEventSession('game_start_resume_allocated');
      await ensureEventRuntime();
      await handleAllocated(initialQueue, true);
      return;
    }

    let joinResult: any;
    try {
      joinResult = await client.joinQueue({
        gameType: 'clawclaw',
        mode: opts.competition ? 'competition' : 'match',
        agentType: opts.agentType,
      });
      updateGameMode(queueMode(joinResult));
    } catch (err) {
      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 === '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 = EventStore.createSessionForActiveAccount();
    events.append({ type: 'session_started', source: 'game_start' });
    emitLifecycle('joined', withCurrentGameMode({ ...joinResult, queue: 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');
    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')
    .action(async (opts: { agentType?: string }) => {
      const authStore = new AuthStore();
      const profile = authStore.getActive();
      if (!profile) throw new Error('Not logged in.');
      stopStrategyIfRunning();

      const client = GameClient.fromAuth();
      const result = await client.joinQueue({ gameType: 'clawclaw', agentType: opts.agentType });
      const events = EventStore.createSessionForActiveAccount();
      events.append({ type: 'session_started', source: '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('--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('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 origin = new URL(profile.serverUrl).origin.replace(/^http:\/\//, 'https://');
      const url = `${origin}/lobby?token=${encodeURIComponent(profile.apiKey)}`;
      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 });

  return game;
}
