import fs from 'fs';
import os from 'os';
import path from 'path';

import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { EJCLAW_ENV } from 'ejclaw-runners-shared';

const {
  mockReadEnvFile,
  mockGetActiveCodexAuthPath,
  mockGetCodexAccountCount,
  mockClaimCodexAuthLease,
  mockFindCodexAccountIndexByAuthPath,
} = vi.hoisted(() => ({
  mockReadEnvFile: vi.fn<() => Record<string, string>>(),
  mockGetActiveCodexAuthPath: vi.fn<() => string | null>(),
  mockGetCodexAccountCount: vi.fn<() => number>(),
  mockClaimCodexAuthLease: vi.fn<
    () => {
      authPath: string;
      accountIndex: number;
      release: () => void;
    } | null
  >(),
  mockFindCodexAccountIndexByAuthPath: vi.fn<() => number | null>(),
}));

vi.mock('./config.js', () => ({
  ASSISTANT_NAME: 'Andy',
  CODEX_REVIEW_SERVICE_ID: 'codex-review',
  GROUPS_DIR: '/tmp/ejclaw-test-groups',
  IS_TEST_ENV: true,
  LOG_LEVEL: 'info',
  SERVICE_ID: 'codex-main',
  SERVICE_SESSION_SCOPE: 'codex-main',
  TIMEZONE: 'Asia/Seoul',
  isReviewService: vi.fn(() => false),
}));

vi.mock('./env.js', () => ({
  readEnvFile: mockReadEnvFile,
  getEnv: vi.fn((_key: string) => undefined),
}));

vi.mock('./codex-token-rotation.js', () => ({
  getActiveCodexAuthPath: mockGetActiveCodexAuthPath,
  getCodexAccountCount: mockGetCodexAccountCount,
  claimCodexAuthLease: mockClaimCodexAuthLease,
  findCodexAccountIndexByAuthPath: mockFindCodexAccountIndexByAuthPath,
}));

vi.mock('./token-rotation.js', () => ({
  getCurrentToken: vi.fn(() => undefined),
  getConfiguredClaudeTokens: vi.fn(
    (options?: { multi?: string | undefined; single?: string | undefined }) => {
      if (options?.multi) {
        return options.multi
          .split(',')
          .map((token) => token.trim())
          .filter(Boolean);
      }
      return options?.single ? [options.single] : [];
    },
  ),
}));

vi.mock('./platform-prompts.js', () => ({
  readPlatformPrompt: vi.fn(() => 'platform prompt'),
  readPairedRoomPrompt: vi.fn(() => 'paired room prompt'),
}));

vi.mock('./service-routing.js', () => ({
  hasReviewerLease: vi.fn(() => false),
  getEffectiveChannelLease: vi.fn(() => ({
    chat_jid: 'dc:test',
    owner_service_id: 'claude',
    reviewer_service_id: 'codex-main',
    arbiter_service_id: null,
    owner_failover_active: false,
    activated_at: null,
    reason: null,
    explicit: false,
  })),
}));

vi.mock('./group-folder.js', () => ({
  resolveGroupFolderPath: (folder: string) =>
    `${process.env.EJ_TEST_ROOT}/groups/${folder}`,
  resolveGroupIpcPath: (folder: string) =>
    `${process.env.EJ_TEST_ROOT}/ipc/${folder}`,
  resolveServiceGroupSessionsPath: (folder: string, serviceId: string) =>
    `${process.env.EJ_TEST_ROOT}/sessions/${folder}/services/${serviceId}`,
  resolveTaskRuntimeIpcPath: (folder: string, taskId: string) =>
    `${process.env.EJ_TEST_ROOT}/task-ipc/${folder}/${taskId}`,
  resolveServiceTaskSessionsPath: (
    folder: string,
    serviceId: string,
    taskId: string,
  ) =>
    `${process.env.EJ_TEST_ROOT}/task-sessions/${folder}/${serviceId}/${taskId}`,
}));

vi.mock('os', async () => {
  const actual = await vi.importActual<typeof import('os')>('os');
  return {
    ...actual,
    default: {
      ...actual,
      homedir: () => process.env.EJ_TEST_HOME || '/tmp',
    },
    homedir: () => process.env.EJ_TEST_HOME || '/tmp',
  };
});

import {
  prepareReadonlySessionEnvironment,
  prepareGroupEnvironment,
} from './agent-runner-environment.js';
import * as config from './config.js';
import * as serviceRouting from './service-routing.js';
import type { RegisteredGroup } from './types.js';

const group: RegisteredGroup = {
  name: 'Codex Test Group',
  folder: 'codex-test-group',
  trigger: '@Codex',
  added_at: new Date().toISOString(),
  agentType: 'codex',
};

function writeSkill(dir: string, name: string): void {
  const skillDir = path.join(dir, name);
  fs.mkdirSync(skillDir, { recursive: true });
  fs.writeFileSync(
    path.join(skillDir, 'SKILL.md'),
    `---\nname: ${name}\ndescription: ${name} skill\n---\n`,
  );
}

function resetRoutingMocks(): void {
  vi.mocked(config.isReviewService).mockReturnValue(false);
  vi.mocked(serviceRouting.hasReviewerLease).mockReturnValue(false);
  vi.mocked(serviceRouting.getEffectiveChannelLease).mockReturnValue({
    chat_jid: 'dc:test',
    owner_service_id: 'claude',
    reviewer_service_id: 'codex-main',
    arbiter_service_id: null,
    owner_failover_active: false,
    activated_at: null,
    reason: null,
    explicit: false,
  });
}

describe('prepareGroupEnvironment codex auth handling', () => {
  let tempRoot: string;
  let previousCwd: string;
  let previousOpenAiKey: string | undefined;
  let previousCodexOpenAiKey: string | undefined;

  beforeEach(() => {
    tempRoot = fs.mkdtempSync(path.join('/tmp', 'ejclaw-agent-env-'));
    previousCwd = process.cwd();
    process.chdir(tempRoot);

    process.env.EJ_TEST_ROOT = tempRoot;
    process.env.EJ_TEST_HOME = path.join(tempRoot, 'home');
    previousOpenAiKey = process.env.OPENAI_API_KEY;
    previousCodexOpenAiKey = process.env.CODEX_OPENAI_API_KEY;
    delete process.env.OPENAI_API_KEY;
    delete process.env.CODEX_OPENAI_API_KEY;

    fs.mkdirSync(process.env.EJ_TEST_HOME, { recursive: true });
    fs.mkdirSync(path.join(process.env.EJ_TEST_HOME, '.codex'), {
      recursive: true,
    });

    mockReadEnvFile.mockReset();
    mockGetActiveCodexAuthPath.mockReset();
    mockGetCodexAccountCount.mockReset();
    mockGetCodexAccountCount.mockReturnValue(0);
    mockClaimCodexAuthLease.mockReset();
    mockClaimCodexAuthLease.mockReturnValue(null);
    mockFindCodexAccountIndexByAuthPath.mockReset();
    mockFindCodexAccountIndexByAuthPath.mockReturnValue(null);
  });

  afterEach(() => {
    process.chdir(previousCwd);
    delete process.env.EJ_TEST_ROOT;
    delete process.env.EJ_TEST_HOME;
    if (previousOpenAiKey) process.env.OPENAI_API_KEY = previousOpenAiKey;
    else delete process.env.OPENAI_API_KEY;
    if (previousCodexOpenAiKey) {
      process.env.CODEX_OPENAI_API_KEY = previousCodexOpenAiKey;
    } else {
      delete process.env.CODEX_OPENAI_API_KEY;
    }
    fs.rmSync(tempRoot, { recursive: true, force: true });
  });

  it('ignores OPENAI_API_KEY and always uses OAuth auth', () => {
    const rotatedAuthPath = path.join(tempRoot, 'rotated-auth.json');
    const rotatedAuth = {
      auth_mode: 'chatgpt',
      tokens: { access_token: 'x' },
    };
    fs.writeFileSync(rotatedAuthPath, JSON.stringify(rotatedAuth));
    mockGetActiveCodexAuthPath.mockReturnValue(rotatedAuthPath);
    mockReadEnvFile.mockReturnValue({
      OPENAI_API_KEY: 'sk-test-api-key',
      CODEX_MODEL: 'gpt-5.4',
    });

    prepareGroupEnvironment(group, false, 'dc:test');

    const authPath = path.join(
      tempRoot,
      'sessions',
      group.folder,
      'services',
      'codex-main',
      '.codex',
      'auth.json',
    );
    const auth = JSON.parse(fs.readFileSync(authPath, 'utf-8')) as {
      auth_mode: string;
      OPENAI_API_KEY?: string;
      tokens?: unknown;
    };

    // API key auth is never used — always OAuth
    expect(auth.auth_mode).toBe('chatgpt');
    expect(auth.OPENAI_API_KEY).toBeUndefined();
    expect(auth.tokens).toEqual({ access_token: 'x' });
  });

  it('falls back to rotated OAuth auth when no API key is configured', () => {
    const rotatedAuthPath = path.join(tempRoot, 'rotated-auth.json');
    const rotatedAuth = {
      auth_mode: 'chatgpt',
      tokens: {
        access_token: 'oauth-access',
        refresh_token: 'oauth-refresh',
      },
    };
    fs.writeFileSync(rotatedAuthPath, JSON.stringify(rotatedAuth));
    mockGetActiveCodexAuthPath.mockReturnValue(rotatedAuthPath);
    mockReadEnvFile.mockReturnValue({});

    prepareGroupEnvironment(group, false, 'dc:test');

    const authPath = path.join(
      tempRoot,
      'sessions',
      group.folder,
      'services',
      'codex-main',
      '.codex',
      'auth.json',
    );
    const auth = JSON.parse(fs.readFileSync(authPath, 'utf-8'));

    expect(auth).toEqual(rotatedAuth);
  });

  it('fails fast instead of launching Codex without OAuth when rotation accounts exist but no lease is available', () => {
    const rotatedAuthPath = path.join(tempRoot, 'rotated-auth.json');
    fs.writeFileSync(
      rotatedAuthPath,
      JSON.stringify({
        auth_mode: 'chatgpt',
        tokens: { access_token: 'locked-token' },
      }),
    );
    mockGetCodexAccountCount.mockReturnValue(1);
    mockClaimCodexAuthLease.mockReturnValue(null);
    mockGetActiveCodexAuthPath.mockReturnValue(rotatedAuthPath);
    mockReadEnvFile.mockReturnValue({});

    expect(() => prepareGroupEnvironment(group, false, 'dc:test')).toThrow(
      /Codex rotation pool unavailable/,
    );

    const authPath = path.join(
      tempRoot,
      'sessions',
      group.folder,
      'services',
      'codex-main',
      '.codex',
      'auth.json',
    );
    expect(fs.existsSync(authPath)).toBe(false);
  });
});

describe('prepareGroupEnvironment prompt stacks', () => {
  let tempRoot: string;
  let previousCwd: string;
  let previousOpenAiKey: string | undefined;
  let previousCodexOpenAiKey: string | undefined;

  beforeEach(() => {
    tempRoot = fs.mkdtempSync(path.join('/tmp', 'ejclaw-agent-env-'));
    previousCwd = process.cwd();
    process.chdir(tempRoot);

    process.env.EJ_TEST_ROOT = tempRoot;
    process.env.EJ_TEST_HOME = path.join(tempRoot, 'home');
    previousOpenAiKey = process.env.OPENAI_API_KEY;
    previousCodexOpenAiKey = process.env.CODEX_OPENAI_API_KEY;
    delete process.env.OPENAI_API_KEY;
    delete process.env.CODEX_OPENAI_API_KEY;

    fs.mkdirSync(process.env.EJ_TEST_HOME, { recursive: true });
    fs.mkdirSync(path.join(process.env.EJ_TEST_HOME, '.codex'), {
      recursive: true,
    });

    mockReadEnvFile.mockReset();
    mockGetActiveCodexAuthPath.mockReset();
    mockGetCodexAccountCount.mockReset();
    mockGetCodexAccountCount.mockReturnValue(0);
    mockClaimCodexAuthLease.mockReset();
    mockClaimCodexAuthLease.mockReturnValue(null);
    mockFindCodexAccountIndexByAuthPath.mockReset();
    mockFindCodexAccountIndexByAuthPath.mockReturnValue(null);
  });

  afterEach(() => {
    process.chdir(previousCwd);
    delete process.env.EJ_TEST_ROOT;
    delete process.env.EJ_TEST_HOME;
    if (previousOpenAiKey) process.env.OPENAI_API_KEY = previousOpenAiKey;
    else delete process.env.OPENAI_API_KEY;
    if (previousCodexOpenAiKey) {
      process.env.CODEX_OPENAI_API_KEY = previousCodexOpenAiKey;
    } else {
      delete process.env.CODEX_OPENAI_API_KEY;
    }
    fs.rmSync(tempRoot, { recursive: true, force: true });
  });

  it('uses the failover owner prompt pack for codex-review when it owns an explicit failover lease', () => {
    vi.mocked(config.isReviewService).mockReturnValue(true);
    vi.mocked(serviceRouting.hasReviewerLease).mockReturnValue(true);
    vi.mocked(serviceRouting.getEffectiveChannelLease).mockReturnValue({
      chat_jid: 'dc:test',
      owner_service_id: 'codex-review',
      reviewer_service_id: 'claude',
      arbiter_service_id: null,
      owner_failover_active: true,
      activated_at: '2026-03-28T00:00:00.000Z',
      reason: 'claude-429',
      explicit: true,
    });
    mockReadEnvFile.mockReturnValue({});

    const promptsDir = path.join(tempRoot, 'prompts');
    fs.mkdirSync(promptsDir, { recursive: true });
    fs.writeFileSync(
      path.join(promptsDir, 'codex-review-platform.md'),
      'review platform prompt\n',
    );
    fs.writeFileSync(
      path.join(promptsDir, 'owner-common-platform.md'),
      'owner common platform prompt\n',
    );
    fs.writeFileSync(
      path.join(promptsDir, 'owner-common-paired-room.md'),
      'owner common paired prompt\n',
    );
    fs.writeFileSync(
      path.join(promptsDir, 'codex-review-failover-platform.md'),
      'failover platform prompt\n',
    );
    prepareGroupEnvironment(
      { ...group, workDir: path.join(tempRoot, 'workdir') },
      false,
      'dc:test',
      {
        memoryBriefing: 'memory briefing',
      },
    );

    const agentsPath = path.join(
      tempRoot,
      'sessions',
      group.folder,
      'services',
      'codex-main',
      '.codex',
      'AGENTS.md',
    );
    const agents = fs.readFileSync(agentsPath, 'utf-8');
    const segments = agents.trim().split('\n\n---\n\n');

    expect(segments).toEqual([
      'owner common platform prompt',
      'failover platform prompt',
      'owner common paired prompt',
      'memory briefing',
    ]);
  });

  it('adds only the shared owner prompt fragments to Claude session prompts', () => {
    vi.mocked(serviceRouting.hasReviewerLease).mockReturnValue(true);
    mockReadEnvFile.mockReturnValue({});

    const promptsDir = path.join(tempRoot, 'prompts');
    fs.mkdirSync(promptsDir, { recursive: true });
    fs.writeFileSync(
      path.join(promptsDir, 'owner-common-platform.md'),
      'owner common platform prompt\n',
    );
    fs.writeFileSync(
      path.join(promptsDir, 'owner-common-paired-room.md'),
      'owner common paired prompt\n',
    );

    prepareGroupEnvironment(
      { ...group, agentType: 'claude-code' },
      false,
      'dc:test',
      {
        memoryBriefing: 'memory briefing',
      },
    );

    const claudePath = path.join(
      tempRoot,
      'sessions',
      group.folder,
      'services',
      'codex-main',
      '.claude',
      'CLAUDE.md',
    );
    const claude = fs.readFileSync(claudePath, 'utf-8');
    const segments = claude.trim().split('\n\n---\n\n');

    expect(segments).toEqual([
      'owner common platform prompt',
      'platform prompt',
      'owner common paired prompt',
      'memory briefing',
    ]);
  });

  it('maps the canonical multi-token env to a single runner OAuth token', () => {
    vi.mocked(serviceRouting.hasReviewerLease).mockReturnValue(true);
    mockReadEnvFile.mockReturnValue({
      CLAUDE_CODE_OAUTH_TOKENS: 'token-a, token-b',
    });

    const prepared = prepareGroupEnvironment(
      { ...group, agentType: 'claude-code' },
      false,
      'dc:test',
    );

    expect(prepared.env.CLAUDE_CODE_OAUTH_TOKEN).toBe('token-a');
  });

  it('returns to the normal owner prompt stack after failover is cleared', () => {
    vi.mocked(config.isReviewService).mockReturnValue(true);
    vi.mocked(serviceRouting.hasReviewerLease).mockReturnValue(true);
    vi.mocked(serviceRouting.getEffectiveChannelLease).mockReturnValue({
      chat_jid: 'dc:test',
      owner_service_id: 'claude',
      reviewer_service_id: 'codex-main',
      arbiter_service_id: null,
      owner_failover_active: false,
      activated_at: null,
      reason: null,
      explicit: false,
    });
    mockReadEnvFile.mockReturnValue({});

    const promptsDir = path.join(tempRoot, 'prompts');
    fs.mkdirSync(promptsDir, { recursive: true });
    prepareGroupEnvironment(group, false, 'dc:test');

    const agentsPath = path.join(
      tempRoot,
      'sessions',
      group.folder,
      'services',
      'codex-main',
      '.codex',
      'AGENTS.md',
    );
    const agents = fs.readFileSync(agentsPath, 'utf-8');
    const segments = agents.trim().split('\n\n---\n\n');

    expect(segments).toEqual(['platform prompt']);
  });
});

describe('prepareGroupEnvironment Codex MCP room role env', () => {
  let tempRoot: string;
  let previousCwd: string;

  beforeEach(() => {
    tempRoot = fs.mkdtempSync(path.join('/tmp', 'ejclaw-agent-env-'));
    previousCwd = process.cwd();
    process.chdir(tempRoot);

    process.env.EJ_TEST_ROOT = tempRoot;
    process.env.EJ_TEST_HOME = path.join(tempRoot, 'home');
    fs.mkdirSync(process.env.EJ_TEST_HOME, { recursive: true });

    mockReadEnvFile.mockReset();
    mockGetActiveCodexAuthPath.mockReset();
    mockGetCodexAccountCount.mockReset();
    mockGetCodexAccountCount.mockReturnValue(0);
    mockClaimCodexAuthLease.mockReset();
    mockClaimCodexAuthLease.mockReturnValue(null);
    mockFindCodexAccountIndexByAuthPath.mockReset();
    mockFindCodexAccountIndexByAuthPath.mockReturnValue(null);
    resetRoutingMocks();
  });

  afterEach(() => {
    process.chdir(previousCwd);
    delete process.env.EJ_TEST_ROOT;
    delete process.env.EJ_TEST_HOME;
    fs.rmSync(tempRoot, { recursive: true, force: true });
  });

  function writeMcpServer(): void {
    const mcpServerPath = path.join(
      tempRoot,
      'runners',
      'agent-runner',
      'dist',
      'ipc-mcp-stdio.js',
    );
    fs.mkdirSync(path.dirname(mcpServerPath), { recursive: true });
    fs.writeFileSync(mcpServerPath, '// test mcp server\n');
  }

  function readCodexConfigToml(): string {
    return fs.readFileSync(
      path.join(
        tempRoot,
        'sessions',
        group.folder,
        'services',
        'codex-main',
        '.codex',
        'config.toml',
      ),
      'utf-8',
    );
  }

  it('writes paired owner role into the Codex MCP config env', () => {
    mockReadEnvFile.mockReturnValue({});
    writeMcpServer();

    prepareGroupEnvironment(group, false, 'dc:test', {
      roomRole: 'owner',
    });

    expect(readCodexConfigToml()).toContain(`${EJCLAW_ENV.roomRole} = "owner"`);
  });

  it('omits room role from non-paired Codex MCP config env', () => {
    mockReadEnvFile.mockReturnValue({});
    writeMcpServer();

    prepareGroupEnvironment(group, true, 'dc:test');

    expect(readCodexConfigToml()).not.toContain(EJCLAW_ENV.roomRole);
  });
});

describe('prepareGroupEnvironment room skill overrides', () => {
  let tempRoot: string;
  let previousCwd: string;

  beforeEach(() => {
    tempRoot = fs.mkdtempSync(path.join('/tmp', 'ejclaw-agent-skills-'));
    previousCwd = process.cwd();
    process.chdir(tempRoot);
    process.env.EJ_TEST_ROOT = tempRoot;
    process.env.EJ_TEST_HOME = path.join(tempRoot, 'home');
    fs.mkdirSync(path.join(process.env.EJ_TEST_HOME, '.codex'), {
      recursive: true,
    });
    mockReadEnvFile.mockReset();
    mockGetActiveCodexAuthPath.mockReset();
    mockGetCodexAccountCount.mockReset();
    mockGetCodexAccountCount.mockReturnValue(0);
    mockClaimCodexAuthLease.mockReset();
    mockClaimCodexAuthLease.mockReturnValue(null);
    mockFindCodexAccountIndexByAuthPath.mockReset();
    mockFindCodexAccountIndexByAuthPath.mockReturnValue(null);
  });

  afterEach(() => {
    process.chdir(previousCwd);
    delete process.env.EJ_TEST_ROOT;
    delete process.env.EJ_TEST_HOME;
    fs.rmSync(tempRoot, { recursive: true, force: true });
  });

  it('filters Claude session skills with room overrides at spawn time', () => {
    mockReadEnvFile.mockReturnValue({});
    const homeSkills = path.join(
      process.env.EJ_TEST_HOME!,
      '.claude',
      'skills',
    );
    const workDir = path.join(tempRoot, 'workdir');
    const runnerSkills = path.join(tempRoot, 'runners', 'skills');
    writeSkill(homeSkills, 'claude-keep');
    writeSkill(path.join(workDir, '.claude', 'skills'), 'workdir-keep');
    writeSkill(runnerSkills, 'runner-keep');
    writeSkill(runnerSkills, 'runner-off');

    prepareGroupEnvironment(
      { ...group, agentType: 'claude-code', workDir },
      false,
      'dc:test',
      {
        skillOverrides: [
          {
            chatJid: 'dc:test',
            agentType: 'claude-code',
            skillScope: 'runner',
            skillName: 'runner-off',
            enabled: false,
            createdAt: '2026-05-04T00:00:00.000Z',
            updatedAt: '2026-05-04T00:00:00.000Z',
          },
        ],
      },
    );

    const sessionSkills = path.join(
      tempRoot,
      'sessions',
      group.folder,
      'services',
      'codex-main',
      '.claude',
      'skills',
    );
    expect(fs.existsSync(path.join(sessionSkills, 'claude-keep'))).toBe(true);
    expect(fs.existsSync(path.join(sessionSkills, 'workdir-keep'))).toBe(true);
    expect(fs.existsSync(path.join(sessionSkills, 'runner-keep'))).toBe(true);
    expect(fs.existsSync(path.join(sessionSkills, 'runner-off'))).toBe(false);
  });

  it('uses a session-scoped Codex home when room overrides disable skills', () => {
    mockReadEnvFile.mockReturnValue({});
    const codexSkills = path.join(
      process.env.EJ_TEST_HOME!,
      '.agents',
      'skills',
    );
    const runnerSkills = path.join(tempRoot, 'runners', 'skills');
    writeSkill(codexSkills, 'codex-keep');
    writeSkill(codexSkills, 'codex-off');
    writeSkill(runnerSkills, 'runner-keep');
    writeSkill(runnerSkills, 'runner-off');

    const prepared = prepareGroupEnvironment(group, false, 'dc:test', {
      skillOverrides: [
        {
          chatJid: 'dc:test',
          agentType: 'codex',
          skillScope: 'codex-user',
          skillName: 'codex-off',
          enabled: false,
          createdAt: '2026-05-04T00:00:00.000Z',
          updatedAt: '2026-05-04T00:00:00.000Z',
        },
        {
          chatJid: 'dc:test',
          agentType: 'codex',
          skillScope: 'runner',
          skillName: 'runner-off',
          enabled: false,
          createdAt: '2026-05-04T00:00:00.000Z',
          updatedAt: '2026-05-04T00:00:00.000Z',
        },
      ],
    });

    const sessionRoot = path.join(
      tempRoot,
      'sessions',
      group.folder,
      'services',
      'codex-main',
    );
    const sessionHome = path.join(sessionRoot, 'home');
    const sessionSkills = path.join(sessionHome, '.agents', 'skills');
    expect(prepared.env.HOME).toBe(sessionHome);
    expect(prepared.env.CODEX_HOME).toBe(path.join(sessionRoot, '.codex'));
    expect(fs.existsSync(path.join(sessionSkills, 'codex-keep'))).toBe(true);
    expect(fs.existsSync(path.join(sessionSkills, 'runner-keep'))).toBe(true);
    expect(fs.existsSync(path.join(sessionSkills, 'codex-off'))).toBe(false);
    expect(fs.existsSync(path.join(sessionSkills, 'runner-off'))).toBe(false);
    expect(fs.existsSync(path.join(codexSkills, 'codex-off'))).toBe(true);
  });
});

describe('prepareGroupEnvironment Codex goals handling', () => {
  let tempRoot: string;
  let previousCwd: string;
  let previousCodexGoals: string | undefined;

  beforeEach(() => {
    tempRoot = fs.mkdtempSync(path.join('/tmp', 'ejclaw-agent-env-goals-'));
    previousCwd = process.cwd();
    previousCodexGoals = process.env.CODEX_GOALS;
    process.chdir(tempRoot);
    process.env.EJ_TEST_ROOT = tempRoot;
    process.env.EJ_TEST_HOME = path.join(tempRoot, 'home');
    delete process.env.CODEX_GOALS;

    fs.mkdirSync(path.join(process.env.EJ_TEST_HOME, '.codex'), {
      recursive: true,
    });

    mockReadEnvFile.mockReset();
    mockGetActiveCodexAuthPath.mockReset();
    mockGetCodexAccountCount.mockReset();
    mockGetCodexAccountCount.mockReturnValue(0);
    mockClaimCodexAuthLease.mockReset();
    mockClaimCodexAuthLease.mockReturnValue(null);
    mockFindCodexAccountIndexByAuthPath.mockReset();
    mockFindCodexAccountIndexByAuthPath.mockReturnValue(null);
    vi.mocked(config.isReviewService).mockReturnValue(false);
    vi.mocked(serviceRouting.hasReviewerLease).mockReturnValue(false);
    vi.mocked(serviceRouting.getEffectiveChannelLease).mockReturnValue({
      chat_jid: 'dc:test',
      owner_service_id: 'claude',
      reviewer_service_id: 'codex-main',
      arbiter_service_id: null,
      owner_failover_active: false,
      activated_at: null,
      reason: null,
      explicit: false,
    });
  });

  afterEach(() => {
    process.chdir(previousCwd);
    delete process.env.EJ_TEST_ROOT;
    delete process.env.EJ_TEST_HOME;
    if (previousCodexGoals) process.env.CODEX_GOALS = previousCodexGoals;
    else delete process.env.CODEX_GOALS;
    fs.rmSync(tempRoot, { recursive: true, force: true });
  });

  it('keeps Codex goals disabled by default and enables them only via opt-in config', () => {
    mockReadEnvFile.mockReturnValue({});

    const defaultPrepared = prepareGroupEnvironment(group, false, 'dc:test');
    expect(defaultPrepared.env.CODEX_GOALS).toBeUndefined();

    const enabledPrepared = prepareGroupEnvironment(
      {
        ...group,
        agentConfig: {
          codexGoals: true,
        },
      },
      false,
      'dc:test',
    );
    expect(enabledPrepared.env.CODEX_GOALS).toBe('true');
  });

  it('allows CODEX_GOALS env opt-in for Codex runner sessions', () => {
    mockReadEnvFile.mockReturnValue({
      CODEX_GOALS: 'true',
    });

    const prepared = prepareGroupEnvironment(group, false, 'dc:test');

    expect(prepared.env.CODEX_GOALS).toBe('true');
  });

  it('enables goals from host ~/.codex/config.toml [features]', () => {
    mockReadEnvFile.mockReturnValue({});
    const homedirSpy = vi
      .spyOn(os, 'homedir')
      .mockReturnValue(process.env.EJ_TEST_HOME!);
    fs.writeFileSync(
      path.join(process.env.EJ_TEST_HOME!, '.codex', 'config.toml'),
      '[features]\ngoals = true\n',
    );

    try {
      const prepared = prepareGroupEnvironment(group, false, 'dc:test');
      expect(prepared.env.CODEX_GOALS).toBe('true');
    } finally {
      homedirSpy.mockRestore();
    }
  });
});

describe('prepareReadonlySessionEnvironment codex compatibility', () => {
  let tempRoot: string;
  let previousCwd: string;

  beforeEach(() => {
    tempRoot = fs.mkdtempSync(path.join('/tmp', 'ejclaw-readonly-env-'));
    previousCwd = process.cwd();
    process.chdir(tempRoot);

    process.env.EJ_TEST_ROOT = tempRoot;
    process.env.EJ_TEST_HOME = path.join(tempRoot, 'home');

    fs.mkdirSync(process.env.EJ_TEST_HOME, { recursive: true });
    fs.mkdirSync(path.join(process.env.EJ_TEST_HOME, '.codex'), {
      recursive: true,
    });

    mockReadEnvFile.mockReset();
    mockGetActiveCodexAuthPath.mockReset();
    mockGetCodexAccountCount.mockReset();
    mockGetCodexAccountCount.mockReturnValue(0);
    mockClaimCodexAuthLease.mockReset();
    mockClaimCodexAuthLease.mockReturnValue(null);
    mockFindCodexAccountIndexByAuthPath.mockReset();
    mockFindCodexAccountIndexByAuthPath.mockReturnValue(null);
  });

  afterEach(() => {
    process.chdir(previousCwd);
    delete process.env.EJ_TEST_ROOT;
    delete process.env.EJ_TEST_HOME;
    fs.rmSync(tempRoot, { recursive: true, force: true });
  });

  it('does not claim a Codex auth lease for Claude read-only sessions', () => {
    vi.mocked(serviceRouting.hasReviewerLease).mockReturnValue(true);
    mockGetCodexAccountCount.mockReturnValue(1);
    const rotatedAuthPath = path.join(tempRoot, 'rotated-auth.json');
    fs.writeFileSync(rotatedAuthPath, '{"auth_mode":"chatgpt"}\n');
    const release = vi.fn();
    mockClaimCodexAuthLease.mockReturnValue({
      authPath: rotatedAuthPath,
      accountIndex: 0,
      release,
    });

    const sessionDir = path.join(tempRoot, 'readonly-claude-session');
    const prepared = prepareReadonlySessionEnvironment({
      sessionDir,
      chatJid: 'dc:test',
      isMain: false,
      groupFolder: 'codex-test-group',
      agentType: 'claude-code',
      memoryBriefing: 'memory briefing',
      role: 'reviewer',
    });

    expect(prepared.codexSessionAuth).toBeNull();
    expect(mockClaimCodexAuthLease).not.toHaveBeenCalled();
    expect(release).not.toHaveBeenCalled();
    expect(fs.existsSync(path.join(sessionDir, '.codex', 'auth.json'))).toBe(
      false,
    );
  });

  it('writes matching AGENTS.md and copies host codex auth/config into the role-scoped session', () => {
    vi.mocked(serviceRouting.hasReviewerLease).mockReturnValue(true);

    const promptsDir = path.join(tempRoot, 'prompts');
    fs.mkdirSync(promptsDir, { recursive: true });
    const mcpServerPath = path.join(
      tempRoot,
      'runners',
      'agent-runner',
      'dist',
      'ipc-mcp-stdio.js',
    );
    fs.mkdirSync(path.dirname(mcpServerPath), { recursive: true });
    fs.writeFileSync(mcpServerPath, '// test mcp server\n');
    fs.writeFileSync(
      path.join(process.env.EJ_TEST_HOME!, '.codex', 'auth.json'),
      '{"auth_mode":"chatgpt"}\n',
    );
    fs.writeFileSync(
      path.join(process.env.EJ_TEST_HOME!, '.codex', 'config.toml'),
      `model = "gpt-5.4"

[mcp_servers.ejclaw]
command = "node"
args = ["old-ipc.js"]

[mcp_servers.ejclaw.env]
${EJCLAW_ENV.ipcDir} = "/old/ipc"

[mcp_servers.other]
command = "node"
args = ["other.js"]
`,
    );

    const sessionDir = path.join(tempRoot, 'readonly-reviewer-session');
    prepareReadonlySessionEnvironment({
      sessionDir,
      chatJid: 'dc:test',
      isMain: false,
      groupFolder: 'codex-test-group',
      agentType: 'codex',
      memoryBriefing: 'memory briefing',
      role: 'reviewer',
    });

    const claudeMd = fs.readFileSync(
      path.join(sessionDir, 'CLAUDE.md'),
      'utf-8',
    );
    expect(
      fs.readFileSync(path.join(sessionDir, '.codex', 'AGENTS.md'), 'utf-8'),
    ).toBe(claudeMd);
    expect(
      fs.readFileSync(path.join(sessionDir, '.codex', 'auth.json'), 'utf-8'),
    ).toContain('"auth_mode":"chatgpt"');
    expect(
      fs.readFileSync(path.join(sessionDir, '.claude.json'), 'utf-8'),
    ).toBe('{}\n');
    const toml = fs.readFileSync(
      path.join(sessionDir, '.codex', 'config.toml'),
      'utf-8',
    );
    expect(toml).toContain('model = "gpt-5.4"');
    expect(toml).toContain('[mcp_servers.other]');
    expect(toml).toContain('[mcp_servers.ejclaw]');
    expect(toml).toContain(`${EJCLAW_ENV.ipcDir} = "/workspace/ipc"`);
    expect(toml).toContain(`${EJCLAW_ENV.groupFolder} = "codex-test-group"`);
    expect(toml).toContain(`${EJCLAW_ENV.roomRole} = "reviewer"`);
    expect(toml).toContain(`${EJCLAW_ENV.workDir} = "/workspace/project"`);
    expect(toml).not.toContain('old-ipc.js');
    expect(toml).not.toContain('"/old/ipc"');
    expect(toml.match(/\[mcp_servers\.ejclaw\]/g)).toHaveLength(1);
    expect(toml.match(/\[mcp_servers\.ejclaw\.env\]/g)).toHaveLength(1);
  });
});
