import { describe, it, expect, vi } from 'vitest';
import {
  lstatSync,
  mkdtempSync,
  mkdirSync,
  readFileSync,
  rmSync,
  symlinkSync,
  writeFileSync,
} from 'node:fs';
import { tmpdir, homedir } from 'node:os';
import { join } from 'node:path';
import {
  CLAUDEX_CONFIG_DIR_ENV,
  CLAUDEX_DEFAULT_PRIMARY_MODEL,
  CLAUDEX_DEFAULT_SMALL_FAST_MODEL,
  CLAUDEX_CREDENTIAL_ENV_RE,
  defaultClaudexConfigDir,
  assertIsolatedConfigDir,
  resolveClaudexConfigDir,
  resolveClaudexModels,
  buildClaudexEnv,
  buildClaudexBanner,
  buildClaudexContractNote,
  ensureClaudexMutatorGateSettings,
  runClaudexProxyGate,
  launchClaudex,
  type ClaudexHarnessAdapter,
} from './claudex.js';
import { CLAUDEX_PROXY_URL, type PreflightReport } from './claudex-proxy.js';

// ─── helpers ─────────────────────────────────────────────────────────────────

function makeReport(overrides: Partial<PreflightReport> = {}): PreflightReport {
  return {
    binaryPresent: true,
    binaryPath: '/usr/bin/claude-code-proxy',
    auth: { state: 'valid' },
    live: true,
    listenerVerdict: 'ok',
    needsReauth: false,
    ok: true,
    problems: [],
    ...overrides,
  };
}

type TestAdapterOverrides = Partial<ClaudexHarnessAdapter> & {
  exec?: (cmd: string, args: string[], env: NodeJS.ProcessEnv) => void;
};

function okAdapter(overrides: TestAdapterOverrides = {}): ClaudexHarnessAdapter {
  const { exec, ...adapterOverrides } = overrides;
  return {
    harnessPreflight: () => {},
    composePrompt: () => '# Composed Claude contract',
    execLeaseGated:
      adapterOverrides.execLeaseGated ??
      ((args, env, dangerous) =>
        exec?.('claude', dangerous ? ['--dangerously-skip-permissions', ...args] : args, env)),
    ...adapterOverrides,
  };
}

// Identity canonicalizer + no-op FS deps so config-dir logic is tested purely.
const idCanon = (p: string): string => p;
const noFsDeps = { canonicalize: idCanon, mkdir: () => {}, isSymlink: () => false };

// ─── isolated config dir (HARD SECURITY REQ 1 — provable isolation) ───────────

describe('defaultClaudexConfigDir', () => {
  it('is namespaced under the mosaic home, never ~/.claude', () => {
    const dir = defaultClaudexConfigDir('/home/agent/.config/mosaic');
    expect(dir).toBe(join('/home/agent/.config/mosaic', 'claudex', 'home'));
    expect(dir).not.toBe(join(homedir(), '.claude'));
  });
});

describe('assertIsolatedConfigDir — the isolation guard is provable', () => {
  const realClaude = '/home/agent/.claude';

  it('accepts a dir that does not resolve to ~/.claude', () => {
    const safe = '/home/agent/.config/mosaic/claudex/home';
    expect(
      assertIsolatedConfigDir(safe, { realClaudeDir: realClaude, canonicalize: idCanon }),
    ).toBe(safe);
  });

  it('REJECTS a candidate that is literally ~/.claude', () => {
    expect(() =>
      assertIsolatedConfigDir(realClaude, { realClaudeDir: realClaude, canonicalize: idCanon }),
    ).toThrow(/refusing/i);
  });

  it('REJECTS a descendant of ~/.claude (would pollute the real tree)', () => {
    expect(() =>
      assertIsolatedConfigDir('/home/agent/.claude/projects/x', {
        realClaudeDir: realClaude,
        canonicalize: idCanon,
      }),
    ).toThrow(/refusing/i);
  });

  it('REJECTS a candidate that canonically resolves to ~/.claude (symlink, both sides canonicalized)', () => {
    const canon = (p: string): string => (p === '/home/agent/link' ? realClaude : p);
    expect(() =>
      assertIsolatedConfigDir('/home/agent/link', {
        realClaudeDir: realClaude,
        canonicalize: canon,
      }),
    ).toThrow(/refusing/i);
  });

  it('canonicalizes the ~/.claude side too (real dir itself may be a symlink)', () => {
    // realClaudeDir is a symlink whose canonical target equals the candidate's target.
    const canon = (p: string): string =>
      p === '/home/agent/.claude' || p === '/home/agent/link' ? '/canonical/claude' : p;
    expect(() =>
      assertIsolatedConfigDir('/home/agent/link', {
        realClaudeDir: realClaude,
        canonicalize: canon,
      }),
    ).toThrow(/refusing/i);
  });

  it('REJECTS an empty or whitespace candidate (fail closed)', () => {
    expect(() =>
      assertIsolatedConfigDir('', { realClaudeDir: realClaude, canonicalize: idCanon }),
    ).toThrow();
    expect(() =>
      assertIsolatedConfigDir('   ', { realClaudeDir: realClaude, canonicalize: idCanon }),
    ).toThrow();
  });

  it('REJECTS a relative candidate (must be absolute)', () => {
    expect(() =>
      assertIsolatedConfigDir('relative/dir', { realClaudeDir: realClaude, canonicalize: idCanon }),
    ).toThrow(/absolute/i);
  });
});

describe('resolveClaudexConfigDir', () => {
  it('uses the namespaced default and never the ambient CLAUDE_CONFIG_DIR', () => {
    // Ambient CLAUDE_CONFIG_DIR is deliberately ignored (it could be ~/.claude).
    const env = { CLAUDE_CONFIG_DIR: join(homedir(), '.claude') };
    const dir = resolveClaudexConfigDir(env, {
      mosaicHome: '/home/agent/.config/mosaic',
      realClaudeDir: '/home/agent/.claude',
      ...noFsDeps,
    });
    expect(dir).toBe(join('/home/agent/.config/mosaic', 'claudex', 'home'));
  });

  it('honors the dedicated override env when it is safe', () => {
    const env = { [CLAUDEX_CONFIG_DIR_ENV]: '/home/agent/custom-claudex' };
    const dir = resolveClaudexConfigDir(env, {
      mosaicHome: '/home/agent/.config/mosaic',
      realClaudeDir: '/home/agent/.claude',
      ...noFsDeps,
    });
    expect(dir).toBe('/home/agent/custom-claudex');
  });

  it('REJECTS a dedicated override that points at ~/.claude (before creating anything)', () => {
    const mkdir = vi.fn();
    const env = { [CLAUDEX_CONFIG_DIR_ENV]: '/home/agent/.claude' };
    expect(() =>
      resolveClaudexConfigDir(env, {
        mosaicHome: '/home/agent/.config/mosaic',
        realClaudeDir: '/home/agent/.claude',
        canonicalize: idCanon,
        mkdir,
        isSymlink: () => false,
      }),
    ).toThrow(/refusing/i);
    expect(mkdir).not.toHaveBeenCalled();
  });

  it('TOCTOU: REJECTS when the created target is itself a symlink (pre-created race)', () => {
    const env = {};
    expect(() =>
      resolveClaudexConfigDir(env, {
        mosaicHome: '/home/agent/.config/mosaic',
        realClaudeDir: '/home/agent/.claude',
        canonicalize: idCanon,
        mkdir: () => {},
        isSymlink: () => true, // the just-ensured dir is a symlink → fail closed
      }),
    ).toThrow(/refusing|symlink/i);
  });

  it('real-FS: creates the isolated dir 0700 and returns its canonical path', () => {
    const root = mkdtempSync(join(tmpdir(), 'claudex-cfg-'));
    try {
      const mosaicHome = join(root, '.config', 'mosaic');
      const dir = resolveClaudexConfigDir({}, { mosaicHome, realClaudeDir: join(root, '.claude') });
      expect(dir).toBe(join(mosaicHome, 'claudex', 'home'));
      const st = lstatSync(dir);
      expect(st.isDirectory()).toBe(true);
      // 0700 (owner-only) — mask off the type bits.
      expect(st.mode & 0o777).toBe(0o700);
    } finally {
      rmSync(root, { recursive: true, force: true });
    }
  });

  it('real-FS: catches an override whose ancestor symlinks into ~/.claude', () => {
    const root = mkdtempSync(join(tmpdir(), 'claudex-cfg-'));
    try {
      const realClaudeDir = join(root, 'dot-claude');
      mkdirSync(realClaudeDir, { recursive: true });
      const link = join(root, 'link'); // link -> dot-claude
      symlinkSync(realClaudeDir, link, 'dir');
      const override = join(link, 'sub'); // resolves under ~/.claude
      expect(() =>
        resolveClaudexConfigDir(
          { [CLAUDEX_CONFIG_DIR_ENV]: override },
          { mosaicHome: join(root, '.config', 'mosaic'), realClaudeDir },
        ),
      ).toThrow(/refusing/i);
    } finally {
      rmSync(root, { recursive: true, force: true });
    }
  });

  it('FAIL CLOSED: default canonicalizer rethrows a non-ENOENT error (ELOOP) instead of a literal fallback', () => {
    // A symlink loop makes realpathSync throw ELOOP. The guard must NOT swallow
    // it as "does not exist yet, keep walking up" and return a literal path —
    // it must fail closed. (REQ 1: fails CLOSED on any uncertainty.)
    const root = mkdtempSync(join(tmpdir(), 'claudex-loop-'));
    try {
      const a = join(root, 'a');
      const b = join(root, 'b');
      symlinkSync(b, a, 'dir'); // a -> b
      symlinkSync(a, b, 'dir'); // b -> a  (loop)
      const looped = join(a, 'home'); // canonicalizing this hits ELOOP
      // No canonicalize dep → the real defaultCanonicalizeIntended runs.
      expect(() =>
        assertIsolatedConfigDir(looped, { realClaudeDir: join(root, '.claude') }),
      ).toThrow();
    } finally {
      rmSync(root, { recursive: true, force: true });
    }
  });

  it('FAIL CLOSED: default isSymlink rethrows a non-ENOENT error (ENOTDIR) rather than reporting "not a symlink"', () => {
    // A candidate whose parent is a regular FILE makes lstat throw ENOTDIR.
    // The post-create symlink check must fail closed, not treat it as safe.
    const root = mkdtempSync(join(tmpdir(), 'claudex-notdir-'));
    try {
      const file = join(root, 'afile');
      writeFileSync(file, 'x');
      const candidate = join(file, 'child'); // parent is a file → ENOTDIR on lstat
      expect(() =>
        // Bypass the guard/mkdir side-effects; only the default isSymlink runs live.
        resolveClaudexConfigDir(
          { [CLAUDEX_CONFIG_DIR_ENV]: candidate },
          {
            realClaudeDir: join(root, '.claude'),
            canonicalize: idCanon,
            mkdir: () => {},
            // isSymlink omitted → real defaultIsSymlink runs on the ENOTDIR path.
          },
        ),
      ).toThrow();
    } finally {
      rmSync(root, { recursive: true, force: true });
    }
  });

  it('FAIL CLOSED: an injected canonicalize throwing EACCES is not swallowed', () => {
    const eacces = Object.assign(new Error('permission denied'), { code: 'EACCES' });
    expect(() =>
      resolveClaudexConfigDir(
        { [CLAUDEX_CONFIG_DIR_ENV]: '/home/agent/custom-claudex' },
        {
          realClaudeDir: '/home/agent/.claude',
          canonicalize: () => {
            throw eacces;
          },
          mkdir: () => {},
          isSymlink: () => false,
        },
      ),
    ).toThrow(/permission denied/);
  });
});

// ─── model-tier map (P3) ──────────────────────────────────────────────────────

describe('resolveClaudexModels', () => {
  it('defaults primary=sol / smallFast=luna', () => {
    expect(resolveClaudexModels({})).toEqual({
      primary: CLAUDEX_DEFAULT_PRIMARY_MODEL,
      smallFast: CLAUDEX_DEFAULT_SMALL_FAST_MODEL,
    });
    expect(CLAUDEX_DEFAULT_PRIMARY_MODEL).toBe('gpt-5.6-sol');
    expect(CLAUDEX_DEFAULT_SMALL_FAST_MODEL).toBe('gpt-5.6-luna');
  });

  it('env-provided values WIN over defaults', () => {
    expect(
      resolveClaudexModels({ ANTHROPIC_MODEL: 'gpt-x', ANTHROPIC_SMALL_FAST_MODEL: 'gpt-y' }),
    ).toEqual({ primary: 'gpt-x', smallFast: 'gpt-y' });
  });

  it('ignores blank env values (falls back to defaults)', () => {
    expect(
      resolveClaudexModels({ ANTHROPIC_MODEL: '   ', ANTHROPIC_SMALL_FAST_MODEL: '' }),
    ).toEqual({
      primary: CLAUDEX_DEFAULT_PRIMARY_MODEL,
      smallFast: CLAUDEX_DEFAULT_SMALL_FAST_MODEL,
    });
  });
});

// ─── env injection (HARD SECURITY REQ 2 — zero token leakage) ─────────────────

describe('buildClaudexEnv — zero token leakage', () => {
  const models = { primary: 'gpt-5.6-sol', smallFast: 'gpt-5.6-luna' };
  const configDir = '/home/agent/.config/mosaic/claudex/home';

  it('sets only ANTHROPIC_AUTH_TOKEN=unused and points at the loopback proxy', () => {
    const env = buildClaudexEnv({}, { configDir, models });
    expect(env.ANTHROPIC_AUTH_TOKEN).toBe('unused');
    expect(env.ANTHROPIC_BASE_URL).toBe(CLAUDEX_PROXY_URL);
    expect(env.CLAUDE_CONFIG_DIR).toBe(configDir);
    expect(env.ANTHROPIC_MODEL).toBe('gpt-5.6-sol');
    expect(env.ANTHROPIC_SMALL_FAST_MODEL).toBe('gpt-5.6-luna');
  });

  it('OVERWRITES an inherited real auth token with the literal "unused"', () => {
    const env = buildClaudexEnv(
      { ANTHROPIC_AUTH_TOKEN: 'sk-ant-realsecret-should-never-flow' },
      { configDir, models },
    );
    expect(env.ANTHROPIC_AUTH_TOKEN).toBe('unused');
  });

  it('DELETES ANTHROPIC_API_KEY so no real Anthropic key reaches the local proxy', () => {
    const env = buildClaudexEnv(
      { ANTHROPIC_API_KEY: 'sk-ant-api03-realkey' },
      { configDir, models },
    );
    expect('ANTHROPIC_API_KEY' in env).toBe(false);
    expect(env.ANTHROPIC_API_KEY).toBeUndefined();
  });

  it('sweeps the WHOLE credential-bearing env family (token/api-key/secret/oauth), not just two', () => {
    const env = buildClaudexEnv(
      {
        ANTHROPIC_API_KEY: 'sk-ant-api03-leak',
        CLAUDE_CODE_OAUTH_TOKEN: 'oauth-leak',
        SOME_SERVICE_TOKEN: 'tok-leak',
        VENDOR_API_KEY: 'key-leak',
        DB_SECRET: 'secret-leak',
        HARMLESS: 'kept',
        PATH: '/usr/bin',
      },
      { configDir, models },
    );
    expect(env.ANTHROPIC_API_KEY).toBeUndefined();
    expect(env.CLAUDE_CODE_OAUTH_TOKEN).toBeUndefined();
    expect(env.SOME_SERVICE_TOKEN).toBeUndefined();
    expect(env.VENDOR_API_KEY).toBeUndefined();
    expect(env.DB_SECRET).toBeUndefined();
    // Non-credential vars the harness needs are preserved.
    expect(env.HARMLESS).toBe('kept');
    expect(env.PATH).toBe('/usr/bin');
  });

  it('neutralizes Bedrock/Vertex provider switches so Claude cannot bypass the proxy (REQ 2)', () => {
    // CLAUDE_CODE_USE_BEDROCK / _USE_VERTEX are ROUTING switches: their mere
    // presence makes Claude Code route to AWS Bedrock / GCP Vertex against the
    // ambient cloud credential chain — reaching the real Anthropic API and
    // bypassing ANTHROPIC_BASE_URL (the loopback proxy) entirely. They MUST be
    // gone from the composed env regardless of the launching env.
    const env = buildClaudexEnv(
      {
        CLAUDE_CODE_USE_BEDROCK: '1',
        CLAUDE_CODE_USE_VERTEX: '1',
        CLAUDE_CODE_SKIP_BEDROCK_AUTH: '1',
        CLAUDE_CODE_SKIP_VERTEX_AUTH: '1',
        AWS_ACCESS_KEY_ID: 'AKIAREAL',
        AWS_SECRET_ACCESS_KEY: 'realsecret',
        AWS_SESSION_TOKEN: 'realsession',
        AWS_BEARER_TOKEN_BEDROCK: 'bearer-bedrock-real',
        AWS_REGION: 'us-east-1',
        GOOGLE_APPLICATION_CREDENTIALS: '/home/agent/gcp.json',
        GOOGLE_CLOUD_ACCESS_TOKEN: 'gcp-token-real',
        PATH: '/usr/bin',
      },
      { configDir, models },
    );
    // Routing switches gone by construction.
    expect('CLAUDE_CODE_USE_BEDROCK' in env).toBe(false);
    expect('CLAUDE_CODE_USE_VERTEX' in env).toBe(false);
    expect('CLAUDE_CODE_SKIP_BEDROCK_AUTH' in env).toBe(false);
    expect('CLAUDE_CODE_SKIP_VERTEX_AUTH' in env).toBe(false);
    // Cloud credentials swept — none of the Claude-capable creds survive.
    expect(env.AWS_ACCESS_KEY_ID).toBeUndefined();
    expect(env.AWS_SECRET_ACCESS_KEY).toBeUndefined();
    expect(env.AWS_SESSION_TOKEN).toBeUndefined();
    expect(env.AWS_BEARER_TOKEN_BEDROCK).toBeUndefined();
    expect(env.AWS_REGION).toBeUndefined();
    expect(env.GOOGLE_APPLICATION_CREDENTIALS).toBeUndefined();
    expect(env.GOOGLE_CLOUD_ACCESS_TOKEN).toBeUndefined();
    // The proxy routing is still the only path.
    expect(env.ANTHROPIC_BASE_URL).toBe(CLAUDEX_PROXY_URL);
    expect(env.ANTHROPIC_AUTH_TOKEN).toBe('unused');
    expect(env.PATH).toBe('/usr/bin');
  });

  it('closes the mid-string _KEY / _SECRET gap (STRIPE_SECRET_KEY, SSH_PRIVATE_KEY)', () => {
    const env = buildClaudexEnv(
      {
        STRIPE_SECRET_KEY: 'sk-live-real',
        SSH_PRIVATE_KEY: '-----BEGIN OPENSSH PRIVATE KEY-----',
        HARMLESS: 'kept',
      },
      { configDir, models },
    );
    expect(env.STRIPE_SECRET_KEY).toBeUndefined();
    expect(env.SSH_PRIVATE_KEY).toBeUndefined();
    expect(env.HARMLESS).toBe('kept');
  });

  it('no credential-NAMED key in the composed env carries a real-looking value', () => {
    const env = buildClaudexEnv(
      {
        ANTHROPIC_API_KEY: 'sk-ant-api03-leak',
        ANTHROPIC_AUTH_TOKEN: 'access_token_leak',
        SOME_JWT_TOKEN: 'eyJhbGciOiJIUzI1NiJ9.payload.sig',
        REFRESH_SECRET: 'refresh_token_value',
      },
      { configDir, models },
    );
    for (const [name, value] of Object.entries(env)) {
      if (CLAUDEX_CREDENTIAL_ENV_RE.test(name)) {
        // Any surviving credential-named var must carry only a safe sentinel value.
        expect(value).not.toMatch(/sk-(ant|proj)-/);
        expect(value).not.toMatch(/access_token|refresh_token/);
        expect(value).not.toMatch(/eyJ[A-Za-z0-9_-]+\./); // JWT
      }
    }
  });

  it('honors a caller-provided baseUrl override (loopback default otherwise)', () => {
    const env = buildClaudexEnv({}, { configDir, models, baseUrl: 'http://127.0.0.1:9999' });
    expect(env.ANTHROPIC_BASE_URL).toBe('http://127.0.0.1:9999');
  });

  it('returns a fresh object without mutating the base env', () => {
    const base = { EXISTING: 'kept' };
    const env = buildClaudexEnv(base, { configDir, models });
    expect(env.EXISTING).toBe('kept');
    expect(base).not.toHaveProperty('ANTHROPIC_AUTH_TOKEN');
  });
});

// ─── EXPERIMENTAL classification (P4) ─────────────────────────────────────────

describe('buildClaudexBanner / buildClaudexContractNote', () => {
  const models = { primary: 'gpt-5.6-sol', smallFast: 'gpt-5.6-luna' };

  it('banner marks EXPERIMENTAL and names the models + proxy', () => {
    const banner = buildClaudexBanner(models);
    expect(banner).toMatch(/EXPERIMENTAL/);
    expect(banner).toMatch(/gpt-5\.6-sol/);
    expect(banner).toMatch(/gpt-5\.6-luna/);
    expect(banner).toMatch(/claude-code-proxy/);
    expect(banner).not.toMatch(/unused/); // no token material in the banner
  });

  it('contract note classifies the runtime as EXPERIMENTAL GPT-via-proxy', () => {
    const note = buildClaudexContractNote(models);
    expect(note).toMatch(/EXPERIMENTAL/);
    expect(note).toMatch(/gpt-5\.6-sol/);
    expect(note).toMatch(/not.*Anthropic/i);
  });
});

// ─── proxy gate ───────────────────────────────────────────────────────────────

describe('runClaudexProxyGate', () => {
  it('is ok when the first preflight already passes', async () => {
    const preflight = vi.fn().mockResolvedValue(makeReport());
    const ensureProxy = vi.fn();
    const reauth = vi.fn();
    const gate = await runClaudexProxyGate({ preflight, ensureProxy, reauth });
    expect(gate.ok).toBe(true);
    expect(ensureProxy).not.toHaveBeenCalled();
    expect(reauth).not.toHaveBeenCalled();
  });

  it('fails fast when the binary is missing (no reauth, no start)', async () => {
    const preflight = vi
      .fn()
      .mockResolvedValue(
        makeReport({ binaryPresent: false, ok: false, problems: ['binary not found'] }),
      );
    const ensureProxy = vi.fn();
    const reauth = vi.fn();
    const gate = await runClaudexProxyGate({ preflight, ensureProxy, reauth });
    expect(gate.ok).toBe(false);
    expect(reauth).not.toHaveBeenCalled();
    expect(ensureProxy).not.toHaveBeenCalled();
  });

  it('runs device reauth then re-preflights when OAuth needs it', async () => {
    const preflight = vi
      .fn()
      .mockResolvedValueOnce(
        makeReport({
          auth: { state: 'expired' },
          needsReauth: true,
          ok: false,
          problems: ['expired'],
        }),
      )
      .mockResolvedValueOnce(makeReport());
    const reauth = vi.fn().mockReturnValue(0);
    const gate = await runClaudexProxyGate({ preflight, reauth, ensureProxy: vi.fn() });
    expect(reauth).toHaveBeenCalledTimes(1);
    expect(preflight).toHaveBeenCalledTimes(2);
    expect(gate.ok).toBe(true);
  });

  it('does NOT reauth when auth is already valid', async () => {
    const preflight = vi.fn().mockResolvedValue(makeReport());
    const reauth = vi.fn();
    await runClaudexProxyGate({ preflight, reauth, ensureProxy: vi.fn() });
    expect(reauth).not.toHaveBeenCalled();
  });

  it('aborts when device reauth fails', async () => {
    const preflight = vi.fn().mockResolvedValue(
      makeReport({
        auth: { state: 'unauthenticated' },
        needsReauth: true,
        ok: false,
        problems: ['unauth'],
      }),
    );
    const reauth = vi.fn().mockReturnValue(1);
    const gate = await runClaudexProxyGate({ preflight, reauth, ensureProxy: vi.fn() });
    expect(gate.ok).toBe(false);
    expect(gate.problems.join(' ')).toMatch(/re-auth/i);
  });

  it('starts the proxy then re-preflights when nothing is live', async () => {
    const preflight = vi
      .fn()
      .mockResolvedValueOnce(
        makeReport({ live: false, listenerVerdict: 'unknown', ok: false, problems: ['dead'] }),
      )
      .mockResolvedValueOnce(makeReport());
    const ensureProxy = vi.fn().mockResolvedValue({ live: true, method: 'systemd' });
    const gate = await runClaudexProxyGate({ preflight, ensureProxy, reauth: vi.fn() });
    expect(ensureProxy).toHaveBeenCalledTimes(1);
    expect(gate.ok).toBe(true);
  });

  it('aborts (non-sensitive) when the proxy cannot come up trusted', async () => {
    const preflight = vi
      .fn()
      .mockResolvedValue(
        makeReport({ live: false, listenerVerdict: 'unknown', ok: false, problems: ['dead'] }),
      );
    const ensureProxy = vi.fn().mockResolvedValue({ live: false, method: 'untrusted' });
    const gate = await runClaudexProxyGate({ preflight, ensureProxy, reauth: vi.fn() });
    expect(gate.ok).toBe(false);
    expect(gate.problems.join(' ')).toMatch(/untrusted/);
    // Non-sensitive: no token material in surfaced problems.
    expect(gate.problems.join(' ')).not.toMatch(/access_token|refresh_token|sk-/);
  });
});

// ─── launch orchestration (fail-closed ordering) ──────────────────────────────

describe('ensureClaudexMutatorGateSettings', () => {
  it('does not accept a lookalike hook and preserves isolated settings', () => {
    const root = mkdtempSync(join(tmpdir(), 'claudex-gate-settings-'));
    try {
      writeFileSync(
        join(root, 'settings.json'),
        JSON.stringify({
          preserved: true,
          hooks: {
            PreToolUse: [
              {
                matcher: '.*',
                hooks: [{ type: 'command', command: 'echo lease-broker/mutator-gate.py' }],
              },
            ],
          },
        }),
      );

      ensureClaudexMutatorGateSettings(root);

      const settings = JSON.parse(readFileSync(join(root, 'settings.json'), 'utf8')) as {
        preserved: boolean;
        hooks: Record<string, Array<{ matcher: string; hooks: Array<{ command: string }> }>>;
      };
      const commands = settings.hooks['PreToolUse']!.flatMap((entry) =>
        entry.hooks.map((hook) => hook.command),
      );
      expect(settings.preserved).toBe(true);
      expect(commands).toContain(
        'python3 ~/.config/mosaic/tools/lease-broker/mutator-gate.py --runtime claude',
      );
      expect(
        settings.hooks['PreCompact']?.some((entry) =>
          entry.hooks.some((hook) => hook.command.includes('revoke-lease.py')),
        ),
      ).toBe(true);
      expect(settings.hooks['SessionStart']?.map((entry) => entry.matcher)).toEqual(
        expect.arrayContaining(['compact', 'resume|clear']),
      );
      expect(lstatSync(join(root, 'settings.json')).mode & 0o777).toBe(0o600);
    } finally {
      rmSync(root, { recursive: true, force: true });
    }
  });
});

describe('launchClaudex', () => {
  const baseDeps = {
    baseEnv: {},
    proxyGate: () => Promise.resolve({ ok: true, report: makeReport(), problems: [] }),
    resolveConfigDir: () => '/home/agent/.config/mosaic/claudex/home',
    prepareConfig: () => {},
    log: () => {},
    errorLog: () => {},
    fail: (() => {
      throw new Error('exit');
    }) as (code: number) => never,
  };

  it('yolo=true passes --dangerously-skip-permissions + injected env to claude', async () => {
    const exec = vi.fn();
    await launchClaudex(['--print', 'hi'], true, okAdapter({ exec }), baseDeps);
    expect(exec).toHaveBeenCalledTimes(1);
    const [cmd, args, env] = exec.mock.calls[0]!;
    expect(cmd).toBe('claude');
    expect(args[0]).toBe('--dangerously-skip-permissions');
    expect(args).toContain('--append-system-prompt');
    expect(args).toContain('--print');
    expect(args).toContain('hi');
    expect(env.ANTHROPIC_AUTH_TOKEN).toBe('unused');
    expect(env.ANTHROPIC_BASE_URL).toBe(CLAUDEX_PROXY_URL);
    expect(env.CLAUDE_CONFIG_DIR).toBe('/home/agent/.config/mosaic/claudex/home');
    expect(env.ANTHROPIC_MODEL).toBe('gpt-5.6-sol');
  });

  it('non-yolo omits --dangerously-skip-permissions but still injects the proxy env', async () => {
    const exec = vi.fn();
    await launchClaudex([], false, okAdapter({ exec }), baseDeps);
    const [, args, env] = exec.mock.calls[0]!;
    expect(args).not.toContain('--dangerously-skip-permissions');
    expect(args[0]).toBe('--append-system-prompt');
    expect(env.ANTHROPIC_AUTH_TOKEN).toBe('unused');
  });

  it('appends the EXPERIMENTAL contract note to the composed prompt', async () => {
    const exec = vi.fn();
    await launchClaudex([], true, okAdapter({ exec, composePrompt: () => '# BASE' }), baseDeps);
    const args = exec.mock.calls[0]![1] as string[];
    const promptIdx = args.indexOf('--append-system-prompt') + 1;
    expect(args[promptIdx]).toContain('# BASE');
    expect(args[promptIdx]).toMatch(/EXPERIMENTAL/);
  });

  it('runs the harness preflight BEFORE the proxy gate and exec', async () => {
    const order: string[] = [];
    const adapter = okAdapter({
      harnessPreflight: () => order.push('preflight'),
      exec: () => order.push('exec'),
    });
    await launchClaudex([], true, adapter, {
      ...baseDeps,
      proxyGate: () => {
        order.push('gate');
        return Promise.resolve({ ok: true, report: makeReport(), problems: [] });
      },
    });
    expect(order).toEqual(['preflight', 'gate', 'exec']);
  });

  it('FAIL CLOSED: exits WITHOUT exec when the proxy gate fails', async () => {
    const exec = vi.fn();
    const errors: string[] = [];
    await expect(
      launchClaudex([], true, okAdapter({ exec }), {
        ...baseDeps,
        proxyGate: () =>
          Promise.resolve({ ok: false, report: makeReport({ ok: false }), problems: ['no proxy'] }),
        errorLog: (m: string) => errors.push(m),
      }),
    ).rejects.toThrow('exit');
    expect(exec).not.toHaveBeenCalled();
    expect(errors.join('\n')).toMatch(/no proxy/);
  });

  it('FAIL CLOSED: exits WITHOUT exec when the config-dir guard throws', async () => {
    const exec = vi.fn();
    await expect(
      launchClaudex([], true, okAdapter({ exec }), {
        ...baseDeps,
        resolveConfigDir: () => {
          throw new Error('refusing to use ~/.claude');
        },
      }),
    ).rejects.toThrow('exit');
    expect(exec).not.toHaveBeenCalled();
  });

  it('FAIL CLOSED: reports a non-Error throw via String() and still aborts', async () => {
    const exec = vi.fn();
    const errors: string[] = [];
    await expect(
      launchClaudex([], true, okAdapter({ exec }), {
        ...baseDeps,
        resolveConfigDir: () => {
          // A non-Error throw exercises the String(err) branch of the catch.
          throw { toString: () => 'string-shaped failure' };
        },
        errorLog: (m: string) => errors.push(m),
      }),
    ).rejects.toThrow('exit');
    expect(exec).not.toHaveBeenCalled();
    expect(errors.join('\n')).toMatch(/string-shaped failure/);
  });
});

// ─── production DI defaults (fallback-branch coverage; no real proxy touched) ──

describe('production dependency defaults', () => {
  it('assertIsolatedConfigDir defaults realClaudeDir to ~/.claude', () => {
    const safe = join(tmpdir(), 'mosaic-claudex-default-real', 'home');
    // Only canonicalize injected; realClaudeDir falls back to ~/.claude.
    expect(assertIsolatedConfigDir(safe, { canonicalize: idCanon })).toBe(safe);
  });

  it('assertIsolatedConfigDir default canonicalizer resolves a non-existent path', () => {
    // No canonicalize dep → exercises the real realpath-longest-ancestor walk
    // (including the not-yet-existing tail), on a path safely outside ~/.claude.
    const safe = join(tmpdir(), 'mosaic-claudex-canon', 'nested', 'home');
    expect(assertIsolatedConfigDir(safe)).toContain('mosaic-claudex-canon');
  });

  it('resolveClaudexConfigDir defaults mosaicHome when not injected', () => {
    const dir = mkdtempSync(join(tmpdir(), 'claudex-cfg-'));
    try {
      const target = join(dir, 'home');
      // Override env points elsewhere; mosaicHome dep omitted → MOSAIC_HOME default path is exercised.
      const out = resolveClaudexConfigDir(
        { [CLAUDEX_CONFIG_DIR_ENV]: target },
        { canonicalize: idCanon },
      );
      expect(out).toBe(target);
      expect(lstatSync(target).isDirectory()).toBe(true);
    } finally {
      rmSync(dir, { recursive: true, force: true });
    }
  });

  it('runClaudexProxyGate defaults ensureProxy/reauth/log without invoking them on a missing binary', async () => {
    // Only preflight injected; binary missing → returns before the default
    // ensureProxy/reauth thunks could ever reach the real proxy.
    const preflight = vi
      .fn()
      .mockResolvedValue(makeReport({ binaryPresent: false, ok: false, problems: ['missing'] }));
    const gate = await runClaudexProxyGate({ preflight });
    expect(gate.ok).toBe(false);
  });

  it('launchClaudex defaults log/errorLog/fail/baseEnv/models/buildEnv on the success path', async () => {
    const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
    try {
      const exec = vi.fn();
      // Inject only the boundaries that would touch the real proxy/FS; let the
      // rest default. Success path never calls fail/errorLog.
      await launchClaudex([], true, okAdapter({ exec }), {
        proxyGate: () => Promise.resolve({ ok: true, report: makeReport(), problems: [] }),
        resolveConfigDir: () => join(tmpdir(), 'claudex-default-launch'),
      });
      expect(exec).toHaveBeenCalledTimes(1);
      const [, , env] = exec.mock.calls[0]!;
      expect(env.ANTHROPIC_AUTH_TOKEN).toBe('unused');
      expect(env.ANTHROPIC_MODEL).toBe(CLAUDEX_DEFAULT_PRIMARY_MODEL);
    } finally {
      logSpy.mockRestore();
    }
  });
});
