import fs from 'fs';
import os from 'os';
import path from 'path';
import { EJCLAW_ENV } from 'ejclaw-runners-shared';

import {
  GROUPS_DIR,
  SERVICE_ID,
  SERVICE_SESSION_SCOPE,
  TIMEZONE,
  isReviewService,
} from './config.js';
import { logger } from './logger.js';
import { readEnvFile } from './env.js';
import {
  claimCodexAuthLease,
  findCodexAccountIndexByAuthPath,
  getActiveCodexAuthPath,
  getCodexAccountCount,
  type CodexAuthLease,
} from './codex-token-rotation.js';
import { readCodexFeatureFromFile } from './codex-config-features.js';
import { ensureClaudeSessionSettings } from './claude-session-settings.js';
import {
  getConfiguredClaudeTokens,
  getCurrentToken,
} from './token-rotation.js';
import {
  resolveGroupFolderPath,
  resolveGroupIpcPath,
  resolveServiceGroupSessionsPath,
  resolveTaskRuntimeIpcPath,
  resolveServiceTaskSessionsPath,
} from './group-folder.js';
import {
  readArbiterPrompt,
  readPairedRoomPrompt,
  readPlatformPrompt,
} from './platform-prompts.js';
import {
  getEffectiveChannelLease,
  hasReviewerLease,
} from './service-routing.js';
import type { AgentType, PairedRoomRole, RegisteredGroup } from './types.js';
import type { StoredRoomSkillOverride } from './db/rooms.js';

// writeCodexApiKeyAuth removed — Codex uses OAuth only.
// API key auth caused unintended billing.

function syncDirectoryEntries(sources: string[], destination: string): void {
  for (const source of sources) {
    if (!fs.existsSync(source)) continue;
    for (const entry of fs.readdirSync(source)) {
      const srcPath = path.join(source, entry);
      const dstPath = path.join(destination, entry);
      if (fs.statSync(srcPath).isDirectory()) {
        fs.cpSync(srcPath, dstPath, { recursive: true });
      } else {
        fs.mkdirSync(destination, { recursive: true });
        fs.copyFileSync(srcPath, dstPath);
      }
    }
  }
}

type SkillSyncScope = 'codex-user' | 'claude-user' | 'runner' | 'workdir';

interface SkillSyncSource {
  dir: string;
  scope: SkillSyncScope;
}

function getDisabledSkillNamesByScope(
  overrides: StoredRoomSkillOverride[] | undefined,
  agentType: AgentType,
): Map<SkillSyncScope, Set<string>> {
  const disabled = new Map<SkillSyncScope, Set<string>>();
  for (const override of overrides ?? []) {
    if (override.agentType !== agentType || override.enabled !== false)
      continue;
    if (
      override.skillScope !== 'codex-user' &&
      override.skillScope !== 'claude-user' &&
      override.skillScope !== 'runner'
    ) {
      continue;
    }
    const names = disabled.get(override.skillScope) ?? new Set<string>();
    names.add(override.skillName);
    disabled.set(override.skillScope, names);
  }
  return disabled;
}

function hasDisabledSkillOverrides(
  overrides: StoredRoomSkillOverride[] | undefined,
  agentType: AgentType,
): boolean {
  return (overrides ?? []).some(
    (override) =>
      override.agentType === agentType && override.enabled === false,
  );
}

function syncRoomSkillDirectories(args: {
  sources: SkillSyncSource[];
  destination: string;
  agentType: AgentType;
  overrides?: StoredRoomSkillOverride[];
}): void {
  const disabledNamesByScope = getDisabledSkillNamesByScope(
    args.overrides,
    args.agentType,
  );

  fs.rmSync(args.destination, { recursive: true, force: true });
  fs.mkdirSync(args.destination, { recursive: true });

  for (const source of args.sources) {
    if (!fs.existsSync(source.dir)) continue;
    for (const entry of fs.readdirSync(source.dir, { withFileTypes: true })) {
      if (!entry.isDirectory()) continue;
      if (disabledNamesByScope.get(source.scope)?.has(entry.name)) continue;

      const srcPath = path.join(source.dir, entry.name);
      const dstPath = path.join(args.destination, entry.name);
      fs.cpSync(srcPath, dstPath, { recursive: true });
    }
  }
}

function readOptionalPromptFile(
  projectRoot: string,
  filename: string,
): string | undefined {
  const promptPath = path.join(projectRoot, 'prompts', filename);
  if (!fs.existsSync(promptPath)) return undefined;
  const prompt = fs.readFileSync(promptPath, 'utf-8').trim();
  return prompt || undefined;
}

function ensureClaudeGlobalSettingsFile(sessionDir: string): void {
  const settingsFile = path.join(sessionDir, '.claude.json');
  if (fs.existsSync(settingsFile)) return;

  fs.writeFileSync(settingsFile, '{}\n');
}

export interface PreparedCodexSessionAuth {
  canonicalAuthPath: string;
  sessionAuthPath: string;
  accountIndex: number | null;
  lease?: CodexAuthLease;
}

function syncHostCodexSessionFiles(
  sessionCodexDir: string,
): PreparedCodexSessionAuth | null {
  const hostCodexDir = path.join(os.homedir(), '.codex');
  fs.mkdirSync(sessionCodexDir, { recursive: true });

  const authDst = path.join(sessionCodexDir, 'auth.json');
  const hasRotationAccounts = getCodexAccountCount() > 0;
  const lease = claimCodexAuthLease();
  const rotatedAuthSrc =
    lease?.authPath ?? (!hasRotationAccounts ? getActiveCodexAuthPath() : null);
  const fallbackAuthSrc = path.join(hostCodexDir, 'auth.json');
  const authSrc =
    rotatedAuthSrc && fs.existsSync(rotatedAuthSrc)
      ? rotatedAuthSrc
      : !hasRotationAccounts && fs.existsSync(fallbackAuthSrc)
        ? fallbackAuthSrc
        : null;
  if (authSrc) {
    fs.copyFileSync(authSrc, authDst);
  } else if (fs.existsSync(authDst)) {
    fs.unlinkSync(authDst);
  }

  for (const file of ['config.toml', 'config.json']) {
    const src = path.join(hostCodexDir, file);
    const dst = path.join(sessionCodexDir, file);
    if (fs.existsSync(src)) {
      fs.copyFileSync(src, dst);
    }
  }

  if (
    !rotatedAuthSrc ||
    authSrc !== rotatedAuthSrc ||
    !fs.existsSync(authDst)
  ) {
    lease?.release();
    if (hasRotationAccounts) {
      throw new Error(
        'Codex rotation pool unavailable: all rotation accounts are currently dead, rate-limited, or locked; re-auth or clear stale state before launching Codex',
      );
    }
    return null;
  }

  return {
    canonicalAuthPath: rotatedAuthSrc,
    sessionAuthPath: authDst,
    accountIndex:
      lease?.accountIndex ?? findCodexAccountIndexByAuthPath(rotatedAuthSrc),
    ...(lease ? { lease } : {}),
  };
}

function upsertEjclawMcpServerSection(args: {
  sessionConfigPath: string;
  mcpServerPath: string;
  ipcDir: string;
  hostIpcDir: string;
  chatJid: string;
  groupFolder: string;
  isMain: boolean;
  agentType: AgentType;
  roomRole?: PairedRoomRole;
  workDir?: string;
}): void {
  const stripEjclawMcpServerSections = (input: string): string => {
    const lines = input.split('\n');
    const kept: string[] = [];
    let skipping = false;

    for (const line of lines) {
      const trimmed = line.trim();
      const isSectionHeader = trimmed.startsWith('[') && trimmed.endsWith(']');
      const isEjclawSection = /^\[mcp_servers\.ejclaw(?:\.[^\]]+)?\]$/.test(
        trimmed,
      );

      if (isSectionHeader) {
        if (isEjclawSection) {
          skipping = true;
          continue;
        }
        if (skipping) {
          skipping = false;
        }
      }

      if (!skipping) {
        kept.push(line);
      }
    }

    return kept.join('\n').replace(/^\n+/, '');
  };

  let toml = fs.existsSync(args.sessionConfigPath)
    ? fs.readFileSync(args.sessionConfigPath, 'utf-8')
    : '';
  toml = stripEjclawMcpServerSections(toml);
  const tomlEnvLine = (name: string, value: string): string =>
    `${name} = ${JSON.stringify(value)}`;
  const mcpSection = `
[mcp_servers.ejclaw]
command = "node"
args = [${JSON.stringify(args.mcpServerPath)}]

[mcp_servers.ejclaw.env]
${tomlEnvLine(EJCLAW_ENV.ipcDir, args.ipcDir)}
${tomlEnvLine(EJCLAW_ENV.hostIpcDir, args.hostIpcDir)}
${tomlEnvLine(EJCLAW_ENV.chatJid, args.chatJid)}
${tomlEnvLine(EJCLAW_ENV.groupFolder, args.groupFolder)}
${tomlEnvLine(EJCLAW_ENV.isMain, args.isMain ? '1' : '0')}
${tomlEnvLine(EJCLAW_ENV.agentType, args.agentType)}
${args.roomRole ? `${tomlEnvLine(EJCLAW_ENV.roomRole, args.roomRole)}\n` : ''}
${args.workDir ? `${tomlEnvLine(EJCLAW_ENV.workDir, args.workDir)}\n` : ''}
`;
  fs.writeFileSync(args.sessionConfigPath, toml.trimEnd() + '\n' + mcpSection);
}

function buildBaseRunnerEnv(args: {
  group: RegisteredGroup;
  chatJid: string;
  isMain: boolean;
  groupDir: string;
  groupIpcDir: string;
  hostIpcDir: string;
  globalDir: string;
  groupSessionsDir: string;
  agentType: AgentType;
  envVars: Record<string, string>;
  runtimeTaskId?: string;
}): Record<string, string> {
  const cleanEnv = { ...(process.env as Record<string, string>) };
  for (const [key, value] of Object.entries(args.envVars)) {
    if (value && !cleanEnv[key]) cleanEnv[key] = value;
  }
  delete cleanEnv.CLAUDECODE;
  delete cleanEnv.CLAUDE_CODE_ENTRYPOINT;

  const nodeBin = path.dirname(process.execPath);
  const npmGlobalBin = path.join(os.homedir(), '.npm-global', 'bin');
  const currentPath = cleanEnv.PATH || '/usr/local/bin:/usr/bin:/bin';
  const extraPaths = [nodeBin, npmGlobalBin].filter(
    (candidate) => !currentPath.includes(candidate) && fs.existsSync(candidate),
  );

  return {
    ...cleanEnv,
    PATH:
      extraPaths.length > 0
        ? `${extraPaths.join(':')}:${currentPath}`
        : currentPath,
    TZ: TIMEZONE,
    HOME: os.homedir(),
    [EJCLAW_ENV.groupDir]: args.groupDir,
    [EJCLAW_ENV.ipcDir]: args.groupIpcDir,
    [EJCLAW_ENV.hostIpcDir]: args.hostIpcDir,
    [EJCLAW_ENV.globalDir]: args.globalDir,
    ...(args.group.workDir ? { [EJCLAW_ENV.workDir]: args.group.workDir } : {}),
    [EJCLAW_ENV.chatJid]: args.chatJid,
    [EJCLAW_ENV.groupFolder]: args.group.folder,
    [EJCLAW_ENV.isMain]: args.isMain ? '1' : '0',
    [EJCLAW_ENV.agentType]: args.agentType,
    CLAUDE_CONFIG_DIR: args.groupSessionsDir,
    ...(args.runtimeTaskId
      ? { [EJCLAW_ENV.runtimeTaskId]: args.runtimeTaskId }
      : {}),
  };
}

function prepareClaudeEnvironment(args: {
  env: Record<string, string>;
  envVars: Record<string, string>;
  group: RegisteredGroup;
}): void {
  if (args.envVars.ANTHROPIC_API_KEY || process.env.ANTHROPIC_API_KEY) {
    args.env.ANTHROPIC_API_KEY =
      args.envVars.ANTHROPIC_API_KEY || process.env.ANTHROPIC_API_KEY || '';
  }
  if (args.envVars.ANTHROPIC_AUTH_TOKEN || process.env.ANTHROPIC_AUTH_TOKEN) {
    args.env.ANTHROPIC_AUTH_TOKEN =
      args.envVars.ANTHROPIC_AUTH_TOKEN ||
      process.env.ANTHROPIC_AUTH_TOKEN ||
      '';
  }
  if (args.envVars.ANTHROPIC_BASE_URL || process.env.ANTHROPIC_BASE_URL) {
    args.env.ANTHROPIC_BASE_URL =
      args.envVars.ANTHROPIC_BASE_URL || process.env.ANTHROPIC_BASE_URL || '';
  }
  {
    // Token rotation takes priority over static .env value
    const oauthToken =
      getCurrentToken() ||
      getConfiguredClaudeTokens({
        multi:
          args.envVars.CLAUDE_CODE_OAUTH_TOKENS ||
          process.env.CLAUDE_CODE_OAUTH_TOKENS,
        single:
          args.envVars.CLAUDE_CODE_OAUTH_TOKEN ||
          process.env.CLAUDE_CODE_OAUTH_TOKEN,
      })[0];
    if (oauthToken) {
      args.env.CLAUDE_CODE_OAUTH_TOKEN = oauthToken;
    }
  }
  for (const key of [
    'CLAUDE_MODEL',
    'CLAUDE_THINKING',
    'CLAUDE_THINKING_BUDGET',
    'CLAUDE_EFFORT',
  ]) {
    const value =
      args.envVars[key as keyof typeof args.envVars] || process.env[key];
    if (value) args.env[key] = value;
  }
  if (args.group.agentConfig?.claudeModel) {
    args.env.CLAUDE_MODEL = args.group.agentConfig.claudeModel;
  }
  if (args.group.agentConfig?.claudeEffort) {
    args.env.CLAUDE_EFFORT = args.group.agentConfig.claudeEffort;
  }
  if (args.group.agentConfig?.claudeThinking) {
    args.env.CLAUDE_THINKING = args.group.agentConfig.claudeThinking;
  }
  if (args.group.agentConfig?.claudeThinkingBudget) {
    args.env.CLAUDE_THINKING_BUDGET = String(
      args.group.agentConfig.claudeThinkingBudget,
    );
  }
}

function prepareCodexSessionEnvironment(args: {
  env: Record<string, string>;
  envVars: Record<string, string>;
  projectRoot: string;
  group: RegisteredGroup;
  groupDir: string;
  sessionRootDir: string;
  chatJid: string;
  isMain: boolean;
  roomRole?: PairedRoomRole;
  isPairedRoom: boolean;
  useFailoverPromptPack: boolean;
  memoryBriefing?: string;
  skillOverrides?: StoredRoomSkillOverride[];
}): PreparedCodexSessionAuth | null {
  // API key auth intentionally removed — Codex uses OAuth only.
  // Never pass any API key to Codex child process to prevent API billing.
  delete args.env.OPENAI_API_KEY;
  delete args.env.CODEX_OPENAI_API_KEY;

  const codexModel =
    args.group.agentConfig?.codexModel ||
    args.envVars.CODEX_MODEL ||
    process.env.CODEX_MODEL;
  if (codexModel) args.env.CODEX_MODEL = codexModel;

  const codexEffort =
    args.group.agentConfig?.codexEffort ||
    args.envVars.CODEX_EFFORT ||
    process.env.CODEX_EFFORT;
  if (codexEffort) args.env.CODEX_EFFORT = codexEffort;

  const sessionCodexDir = path.join(args.sessionRootDir, '.codex');
  const codexSessionAuth = syncHostCodexSessionFiles(sessionCodexDir);

  const overlayPath = path.join(args.groupDir, '.codex', 'config.toml');
  const sessionConfigPath = path.join(sessionCodexDir, 'config.toml');
  if (fs.existsSync(overlayPath)) {
    const overlayToml = fs.readFileSync(overlayPath, 'utf-8').trim();
    if (overlayToml) {
      const baseToml = fs.existsSync(sessionConfigPath)
        ? fs.readFileSync(sessionConfigPath, 'utf-8').trimEnd()
        : '';
      fs.writeFileSync(
        sessionConfigPath,
        [baseToml, overlayToml].filter(Boolean).join('\n\n') + '\n',
      );
    }
  }

  const goalsFromConfig = readCodexFeatureFromFile(sessionConfigPath, 'goals');
  const codexGoals =
    args.group.agentConfig?.codexGoals ??
    (goalsFromConfig ||
      args.envVars.CODEX_GOALS === 'true' ||
      process.env.CODEX_GOALS === 'true');
  if (codexGoals) {
    args.env.CODEX_GOALS = 'true';
  } else {
    delete args.env.CODEX_GOALS;
  }

  const sessionAgentsPath = path.join(sessionCodexDir, 'AGENTS.md');
  const sessionAgents = (
    args.useFailoverPromptPack
      ? [
          readOptionalPromptFile(args.projectRoot, 'owner-common-platform.md'),
          readOptionalPromptFile(
            args.projectRoot,
            'codex-review-failover-platform.md',
          ),
          args.isPairedRoom
            ? readOptionalPromptFile(
                args.projectRoot,
                'owner-common-paired-room.md',
              )
            : undefined,
          args.memoryBriefing,
        ]
      : [
          readPlatformPrompt('codex', args.projectRoot),
          args.isPairedRoom
            ? readOptionalPromptFile(
                args.projectRoot,
                'owner-common-paired-room.md',
              )
            : undefined,
          args.memoryBriefing,
        ]
  )
    .filter((value): value is string => Boolean(value))
    .join('\n\n---\n\n')
    .trim();
  if (sessionAgents) {
    fs.writeFileSync(sessionAgentsPath, sessionAgents + '\n');
  } else if (fs.existsSync(sessionAgentsPath)) {
    fs.unlinkSync(sessionAgentsPath);
  }

  // Codex reads skills from ~/.agents/skills/ (user-level) and
  // {workDir}/.agents/skills/ (project-level), NOT from .codex/skills/.
  if (hasDisabledSkillOverrides(args.skillOverrides, 'codex')) {
    const sessionHomeDir = path.join(args.sessionRootDir, 'home');
    args.env.HOME = sessionHomeDir;
    syncRoomSkillDirectories({
      sources: [
        {
          dir: path.join(os.homedir(), '.claude', 'skills'),
          scope: 'codex-user',
        },
        {
          dir: path.join(os.homedir(), '.agents', 'skills'),
          scope: 'codex-user',
        },
        {
          dir: path.join(args.projectRoot, 'runners', 'skills'),
          scope: 'runner',
        },
      ],
      destination: path.join(sessionHomeDir, '.agents', 'skills'),
      agentType: 'codex',
      overrides: args.skillOverrides,
    });
  } else {
    // Preserve the historical global sync path when no room override exists.
    const codexSkillsDir = path.join(os.homedir(), '.agents', 'skills');
    syncDirectoryEntries(
      [
        path.join(os.homedir(), '.claude', 'skills'),
        path.join(args.projectRoot, 'runners', 'skills'),
      ],
      codexSkillsDir,
    );
  }

  const mcpServerPath = path.join(
    args.projectRoot,
    'runners',
    'agent-runner',
    'dist',
    'ipc-mcp-stdio.js',
  );
  if (fs.existsSync(mcpServerPath)) {
    upsertEjclawMcpServerSection({
      sessionConfigPath,
      mcpServerPath,
      ipcDir: args.env[EJCLAW_ENV.ipcDir],
      hostIpcDir: args.env[EJCLAW_ENV.hostIpcDir],
      chatJid: args.chatJid,
      groupFolder: args.group.folder,
      isMain: args.isMain,
      agentType: 'codex',
      roomRole: args.roomRole,
      workDir:
        args.env[EJCLAW_ENV.workDir] || args.group.workDir || args.projectRoot,
    });
  }

  delete args.env.ANTHROPIC_API_KEY;
  delete args.env.ANTHROPIC_AUTH_TOKEN;
  delete args.env.ANTHROPIC_BASE_URL;
  delete args.env.CLAUDE_CODE_OAUTH_TOKEN;
  args.env.CODEX_HOME = sessionCodexDir;
  return codexSessionAuth;
}

export interface PreparedGroupEnvironment {
  env: Record<string, string>;
  groupDir: string;
  runnerDir: string;
  codexSessionAuth?: PreparedCodexSessionAuth | null;
}

export interface PreparedReadonlySessionEnvironment {
  codexHomeDir?: string;
  codexSessionAuth?: PreparedCodexSessionAuth | null;
}

export function prepareGroupEnvironment(
  group: RegisteredGroup,
  isMain: boolean,
  chatJid: string,
  options?: {
    memoryBriefing?: string;
    runtimeTaskId?: string;
    useTaskScopedSession?: boolean;
    skillOverrides?: StoredRoomSkillOverride[];
    roomRole?: PairedRoomRole;
  },
): PreparedGroupEnvironment {
  const projectRoot = process.cwd();
  const groupDir = resolveGroupFolderPath(group.folder);
  fs.mkdirSync(groupDir, { recursive: true });

  const runtimeTaskId = options?.runtimeTaskId;
  const useTaskScopedSession =
    options?.useTaskScopedSession === true && Boolean(runtimeTaskId);
  const sessionRootDir =
    runtimeTaskId && useTaskScopedSession
      ? resolveServiceTaskSessionsPath(
          group.folder,
          SERVICE_SESSION_SCOPE,
          runtimeTaskId,
        )
      : resolveServiceGroupSessionsPath(group.folder, SERVICE_SESSION_SCOPE);

  const groupSessionsDir = path.join(sessionRootDir, '.claude');
  fs.mkdirSync(groupSessionsDir, { recursive: true });
  ensureClaudeSessionSettings(groupSessionsDir);

  const workDirClaude = group.workDir
    ? path.join(group.workDir, '.claude')
    : null;
  syncRoomSkillDirectories({
    sources: [
      {
        dir: path.join(os.homedir(), '.claude', 'skills'),
        scope: 'claude-user',
      },
      ...(workDirClaude
        ? [
            {
              dir: path.join(workDirClaude, 'skills'),
              scope: 'workdir' as const,
            },
          ]
        : []),
      { dir: path.join(projectRoot, 'runners', 'skills'), scope: 'runner' },
    ],
    destination: path.join(groupSessionsDir, 'skills'),
    agentType: 'claude-code',
    overrides: options?.skillOverrides,
  });

  const groupIpcDir = runtimeTaskId
    ? resolveTaskRuntimeIpcPath(group.folder, runtimeTaskId)
    : resolveGroupIpcPath(group.folder);
  const hostIpcDir = resolveGroupIpcPath(group.folder);
  fs.mkdirSync(path.join(groupIpcDir, 'messages'), { recursive: true });
  fs.mkdirSync(path.join(groupIpcDir, 'tasks'), { recursive: true });
  fs.mkdirSync(path.join(groupIpcDir, 'input'), { recursive: true });

  const globalDir = path.join(GROUPS_DIR, 'global');
  const globalClaudeMdPath = path.join(globalDir, 'CLAUDE.md');
  const isPairedRoom = hasReviewerLease(chatJid);
  const effectiveLease = getEffectiveChannelLease(chatJid);
  // Canonical lease state now exposes owner failover directly, so prefer the
  // explicit flag over the older CODEX_REVIEW_SERVICE_ID shadow heuristic.
  const useCodexReviewFailoverPromptPack =
    isReviewService(SERVICE_ID) &&
    effectiveLease.owner_failover_active === true;

  const ownerCommonPlatformPrompt = readOptionalPromptFile(
    projectRoot,
    'owner-common-platform.md',
  );
  const claudePlatformPrompt = readPlatformPrompt('claude-code', projectRoot);
  const ownerCommonPairedRoomPrompt = isPairedRoom
    ? readOptionalPromptFile(projectRoot, 'owner-common-paired-room.md')
    : undefined;
  const globalClaudeMemory =
    !isMain && fs.existsSync(globalClaudeMdPath)
      ? fs.readFileSync(globalClaudeMdPath, 'utf-8').trim()
      : undefined;
  // Owner CLAUDE.md: platform rules + owner paired room rules.
  // Reviewer paired room rules are NOT included — those belong to the
  // Read-only reviewer/arbiter session only (via prepareReadonlySessionEnvironment).
  const sessionClaudeMd = [
    ownerCommonPlatformPrompt,
    claudePlatformPrompt,
    ownerCommonPairedRoomPrompt,
    globalClaudeMemory,
    options?.memoryBriefing,
  ]
    .filter((value): value is string => Boolean(value))
    .join('\n\n---\n\n')
    .trim();
  const sessionClaudeMdPath = path.join(groupSessionsDir, 'CLAUDE.md');
  if (sessionClaudeMd) {
    fs.writeFileSync(sessionClaudeMdPath, sessionClaudeMd + '\n');
  } else if (fs.existsSync(sessionClaudeMdPath)) {
    fs.unlinkSync(sessionClaudeMdPath);
  }

  const agentType = group.agentType || 'claude-code';
  const runnerDirName = agentType === 'codex' ? 'codex-runner' : 'agent-runner';
  const runnerDir = path.join(projectRoot, 'runners', runnerDirName);

  const envVars = readEnvFile([
    'ANTHROPIC_API_KEY',
    'ANTHROPIC_AUTH_TOKEN',
    'ANTHROPIC_BASE_URL',
    'CLAUDE_CODE_OAUTH_TOKENS',
    'CLAUDE_CODE_OAUTH_TOKEN',
    'CLAUDE_MODEL',
    'CLAUDE_THINKING',
    'CLAUDE_THINKING_BUDGET',
    'CLAUDE_EFFORT',
    'CODEX_MODEL',
    'CODEX_EFFORT',
    'CODEX_GOALS',
  ]);

  const env = buildBaseRunnerEnv({
    group,
    chatJid,
    isMain,
    groupDir,
    groupIpcDir,
    hostIpcDir,
    globalDir,
    groupSessionsDir,
    agentType,
    envVars,
    runtimeTaskId,
  });

  let codexSessionAuth: PreparedCodexSessionAuth | null = null;
  if (agentType === 'codex') {
    codexSessionAuth = prepareCodexSessionEnvironment({
      env,
      envVars,
      projectRoot,
      group,
      groupDir,
      sessionRootDir,
      chatJid,
      isMain,
      roomRole: options?.roomRole,
      isPairedRoom,
      useFailoverPromptPack: useCodexReviewFailoverPromptPack,
      memoryBriefing: options?.memoryBriefing,
      skillOverrides: options?.skillOverrides,
    });
  } else {
    prepareClaudeEnvironment({ env, envVars, group });
  }

  return { env, groupDir, runnerDir, codexSessionAuth };
}

/**
 * Prepare a role-scoped session directory for a host-based read-only reviewer
 * or arbiter run.
 *
 * Writes CLAUDE.md (platform + paired room prompts + global memory + briefing),
 * syncs skills, and ensures settings.json exist — the same steps that
 * `prepareGroupEnvironment` does for regular host-mode agents, but targeted at
 * an externally provided session directory.
 */
export function prepareReadonlySessionEnvironment(args: {
  sessionDir: string;
  chatJid: string;
  isMain: boolean;
  groupFolder: string;
  agentType: AgentType;
  memoryBriefing?: string;
  role?: 'reviewer' | 'arbiter';
  ipcDir?: string;
  hostIpcDir?: string;
  workDir?: string;
  skillOverrides?: StoredRoomSkillOverride[];
}): PreparedReadonlySessionEnvironment {
  const {
    sessionDir,
    chatJid,
    isMain,
    groupFolder,
    agentType,
    memoryBriefing,
    role = 'reviewer',
    ipcDir = '/workspace/ipc',
    hostIpcDir = ipcDir,
    workDir = '/workspace/project',
    skillOverrides,
  } = args;
  const projectRoot = process.cwd();
  let codexSessionAuth: PreparedCodexSessionAuth | null = null;

  fs.mkdirSync(sessionDir, { recursive: true });
  ensureClaudeSessionSettings(sessionDir);
  ensureClaudeGlobalSettingsFile(sessionDir);

  // Sync skills from host
  syncRoomSkillDirectories({
    sources: [
      {
        dir: path.join(os.homedir(), '.claude', 'skills'),
        scope: agentType === 'codex' ? 'codex-user' : 'claude-user',
      },
      { dir: path.join(projectRoot, 'runners', 'skills'), scope: 'runner' },
    ],
    destination: path.join(sessionDir, 'skills'),
    agentType,
    overrides: skillOverrides,
  });
  const codexHomeDir =
    agentType === 'codex' && hasDisabledSkillOverrides(skillOverrides, 'codex')
      ? sessionDir
      : undefined;
  if (codexHomeDir) {
    syncRoomSkillDirectories({
      sources: [
        {
          dir: path.join(os.homedir(), '.claude', 'skills'),
          scope: 'codex-user',
        },
        {
          dir: path.join(os.homedir(), '.agents', 'skills'),
          scope: 'codex-user',
        },
        { dir: path.join(projectRoot, 'runners', 'skills'), scope: 'runner' },
      ],
      destination: path.join(codexHomeDir, '.agents', 'skills'),
      agentType,
      overrides: skillOverrides,
    });
  }

  // Build CLAUDE.md with role-appropriate prompts (reviewer or arbiter)
  const claudePlatformPrompt = readPlatformPrompt('claude-code', projectRoot);
  const claudePairedRoomPrompt = hasReviewerLease(chatJid)
    ? role === 'arbiter'
      ? readArbiterPrompt(projectRoot)
      : readPairedRoomPrompt('claude-code', projectRoot)
    : undefined;
  const globalDir = path.join(GROUPS_DIR, 'global');
  const globalClaudeMdPath = path.join(globalDir, 'CLAUDE.md');
  const globalClaudeMemory =
    !isMain && fs.existsSync(globalClaudeMdPath)
      ? fs.readFileSync(globalClaudeMdPath, 'utf-8').trim()
      : undefined;

  const sessionClaudeMd = [
    claudePlatformPrompt,
    claudePairedRoomPrompt,
    globalClaudeMemory,
    memoryBriefing,
  ]
    .filter((value): value is string => Boolean(value))
    .join('\n\n---\n\n')
    .trim();

  const sessionClaudeMdPath = path.join(sessionDir, 'CLAUDE.md');
  if (sessionClaudeMd) {
    fs.writeFileSync(sessionClaudeMdPath, sessionClaudeMd + '\n');
    if (agentType === 'codex') {
      const sessionCodexDir = path.join(sessionDir, '.codex');
      codexSessionAuth = syncHostCodexSessionFiles(sessionCodexDir);
      fs.writeFileSync(
        path.join(sessionCodexDir, 'AGENTS.md'),
        sessionClaudeMd + '\n',
      );
      const sessionConfigPath = path.join(sessionCodexDir, 'config.toml');
      const mcpServerPath = path.join(
        projectRoot,
        'runners',
        'agent-runner',
        'dist',
        'ipc-mcp-stdio.js',
      );
      if (fs.existsSync(mcpServerPath)) {
        upsertEjclawMcpServerSection({
          sessionConfigPath,
          mcpServerPath,
          ipcDir,
          hostIpcDir,
          chatJid,
          groupFolder,
          isMain,
          agentType,
          roomRole: role,
          workDir,
        });
      }
    } else {
      fs.rmSync(path.join(sessionDir, '.codex'), {
        recursive: true,
        force: true,
      });
    }
    logger.info(
      {
        sessionDir,
        claudeMdSize: sessionClaudeMd.length,
        hasPlatform: !!claudePlatformPrompt,
        hasPairedRoom: !!claudePairedRoomPrompt,
        hasGlobalMemory: !!globalClaudeMemory,
        hasMemoryBriefing: !!memoryBriefing,
      },
      'Readonly session CLAUDE.md written',
    );
  } else if (fs.existsSync(sessionClaudeMdPath)) {
    fs.unlinkSync(sessionClaudeMdPath);
  }
  return { codexHomeDir, codexSessionAuth };
}
