/**
 * Claudex launch composition (P2–P4 of `mosaic yolo claudex`).
 *
 * Builds the isolated launch environment for running GPT models inside the
 * Claude Code harness via `raine/claude-code-proxy` (ChatGPT-subscription OAuth).
 * PR-1 (`claudex-proxy.ts`) owns the proxy preflight/lifecycle; this module owns
 * the *composition* the launcher hands to Claude Code:
 *
 *   P2  isolated CLAUDE_CONFIG_DIR (provably never the real ~/.claude) + env
 *       injection that leaks ZERO token material;
 *   P3  the model-tier map (primary → gpt-5.6-sol, small/fast → gpt-5.6-luna,
 *       operator env values win);
 *   P4  the EXPERIMENTAL classification banner + composed-contract note.
 *
 * Two hard security invariants (secrev-enforced):
 *   REQ 1 — Provable isolation. {@link assertIsolatedConfigDir} makes the
 *           CLAUDE_CONFIG_DIR seam incapable of resolving to `~/.claude` (or any
 *           descendant of it); it canonicalizes both sides, rejects descendants,
 *           and — after ensuring the dir — re-checks and rejects a symlinked
 *           target (TOCTOU). Fails CLOSED on any uncertainty.
 *   REQ 2 — Zero token leakage. This module never reads the proxy's
 *           `auth.json`; {@link buildClaudexEnv} strips the ENTIRE
 *           credential-bearing env family and hands Claude Code only
 *           `ANTHROPIC_AUTH_TOKEN=unused`. The proxy holds the real credential.
 *
 * Every side-effecting boundary is dependency-injected so the launch path is
 * unit-testable without spawning Claude Code or touching a real config dir.
 */

import { lstatSync, mkdirSync, realpathSync } from 'node:fs';
import { homedir } from 'node:os';
import { dirname, isAbsolute, join, relative, resolve } from 'node:path';
import {
  CLAUDEX_PROXY_URL,
  ensureProxyRunning,
  runDeviceReauth,
  runProxyPreflight,
  type EnsureProxyResult,
  type PreflightReport,
} from './claudex-proxy.js';

const MOSAIC_HOME = process.env['MOSAIC_HOME'] ?? join(homedir(), '.config', 'mosaic');

// ─── Isolated CLAUDE_CONFIG_DIR (HARD SECURITY REQ 1) ─────────────────────────

/** Dedicated override env for the isolated config dir. The ambient
 * `CLAUDE_CONFIG_DIR` is deliberately NOT honored — it may already point at the
 * real `~/.claude` of the launching session. */
export const CLAUDEX_CONFIG_DIR_ENV = 'MOSAIC_CLAUDEX_CONFIG_DIR';

/** The default isolated config dir — structurally under the mosaic home, so it
 * can never equal `~/.claude`. */
export function defaultClaudexConfigDir(mosaicHome: string = MOSAIC_HOME): string {
  return join(mosaicHome, 'claudex', 'home');
}

export interface ConfigDirDeps {
  /** The real Claude state dir to protect (default `~/.claude`). */
  realClaudeDir?: string;
  /** Resolve a path to canonical form, resolving symlinks on the longest
   * existing ancestor (so a not-yet-created dir still canonicalizes). */
  canonicalize?: (p: string) => string;
  /** Ensure the isolated dir exists (mkdir -p, owner-only 0700). */
  mkdir?: (p: string) => void;
  /** Whether a path is itself a symlink (lstat). */
  isSymlink?: (p: string) => boolean;
}

/** Resolve a path to canonical form, resolving symlinks on the LONGEST EXISTING
 * ancestor and re-appending the not-yet-existing tail. A symlinked ancestor that
 * points into `~/.claude` is therefore caught even before the leaf exists. */
function defaultCanonicalizeIntended(p: string): string {
  const abs = resolve(p);
  let existing = abs;
  const tail: string[] = [];
  // Walk up until we hit an existing ancestor (or the filesystem root).
  for (;;) {
    try {
      const real = realpathSync(existing);
      return tail.length > 0 ? join(real, ...tail) : real;
    } catch (err) {
      // ONLY a genuine "does not exist yet" (ENOENT) justifies walking up to an
      // existing ancestor. Any other errno (ELOOP, EACCES, ENOTDIR, …) means we
      // cannot establish the canonical form — fail CLOSED rather than fall back
      // to a possibly-wrong literal path.
      if ((err as NodeJS.ErrnoException).code !== 'ENOENT') throw err;
      const parent = dirname(existing);
      if (parent === existing) return abs; // reached root without an existing prefix
      tail.unshift(existing.slice(parent.length + 1));
      existing = parent;
    }
  }
}

function defaultMkdir(p: string): void {
  mkdirSync(p, { recursive: true, mode: 0o700 });
}

function defaultIsSymlink(p: string): boolean {
  try {
    return lstatSync(p).isSymbolicLink();
  } catch (err) {
    // A missing path is genuinely "not a symlink"; anything else (EACCES, ELOOP,
    // ENOTDIR, …) is uncertainty the guard must not swallow — fail CLOSED.
    if ((err as NodeJS.ErrnoException).code === 'ENOENT') return false;
    throw err;
  }
}

/** True when `child` is `parent` itself or a descendant of it (path-wise). */
function isWithin(child: string, parent: string): boolean {
  if (child === parent) return true;
  const rel = relative(parent, child);
  return rel.length > 0 && !rel.startsWith('..') && !isAbsolute(rel);
}

/**
 * Guard: prove that `candidate` is a legitimate ISOLATED config dir and can
 * never be, resolve to, or live under the real `~/.claude`. Canonicalizes BOTH
 * sides (either may be a symlink), rejects `~/.claude` and any descendant, and
 * fails CLOSED (throws) on an empty/relative candidate. Returns the canonical
 * isolated path on success.
 */
export function assertIsolatedConfigDir(candidate: string, deps: ConfigDirDeps = {}): string {
  const canonicalize = deps.canonicalize ?? defaultCanonicalizeIntended;
  const realClaudeDir = deps.realClaudeDir ?? join(homedir(), '.claude');

  if (typeof candidate !== 'string' || candidate.trim() === '') {
    throw new Error('claudex: isolated CLAUDE_CONFIG_DIR must be a non-empty path (fail closed).');
  }
  if (!isAbsolute(candidate)) {
    throw new Error(
      `claudex: isolated CLAUDE_CONFIG_DIR must be an absolute path: ${JSON.stringify(candidate)}`,
    );
  }

  const canonCandidate = canonicalize(candidate);
  const canonReal = canonicalize(realClaudeDir);

  // Compare canonical forms AND raw resolved forms — belt and suspenders so a
  // canonicalizer that no-ops on a nonexistent real dir still catches the literal.
  if (isWithin(canonCandidate, canonReal) || isWithin(resolve(candidate), resolve(realClaudeDir))) {
    throw new Error(
      `claudex: refusing to use the real Claude config dir (or a descendant of it) as the ` +
        `isolated CLAUDE_CONFIG_DIR. Resolved to ${JSON.stringify(canonCandidate)}.`,
    );
  }

  return canonCandidate;
}

/**
 * Resolve the isolated CLAUDE_CONFIG_DIR: pick the dedicated override or the
 * namespaced default, run the pre-create guard, ensure the dir (0700), then
 * RE-CHECK after creation — reject a symlinked target and re-run the guard on
 * the now-existing (fully canonicalizable) path. This closes the pre-created
 * symlink race (TOCTOU). Every failure throws (fail closed).
 */
export function resolveClaudexConfigDir(
  env: NodeJS.ProcessEnv = process.env,
  deps: ConfigDirDeps & { mosaicHome?: string } = {},
): string {
  const mosaicHome = deps.mosaicHome ?? MOSAIC_HOME;
  const mkdir = deps.mkdir ?? defaultMkdir;
  const isSymlink = deps.isSymlink ?? defaultIsSymlink;

  const override = env[CLAUDEX_CONFIG_DIR_ENV]?.trim();
  const candidate =
    override && override.length > 0 ? override : defaultClaudexConfigDir(mosaicHome);

  // Pre-create guard (before touching the filesystem).
  assertIsolatedConfigDir(candidate, deps);

  // Ensure the dir, then re-verify against the post-create reality.
  mkdir(candidate);
  if (isSymlink(candidate)) {
    throw new Error(
      'claudex: refusing to use the isolated CLAUDE_CONFIG_DIR — the target is a symlink ' +
        '(possible pre-created race). Fail closed.',
    );
  }
  // Re-run the guard now that the leaf exists so canonicalization reflects any
  // symlinked ancestor introduced between the pre-check and mkdir.
  return assertIsolatedConfigDir(candidate, deps);
}

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

export const CLAUDEX_DEFAULT_PRIMARY_MODEL = 'gpt-5.6-sol';
export const CLAUDEX_DEFAULT_SMALL_FAST_MODEL = 'gpt-5.6-luna';

export interface ClaudexModels {
  /** Primary tier (opus/sonnet) → ANTHROPIC_MODEL. */
  primary: string;
  /** Small/fast tier (haiku) → ANTHROPIC_SMALL_FAST_MODEL. */
  smallFast: string;
}

/** Resolve the model-tier map. Operator-provided env values WIN over defaults;
 * blank values fall back to the defaults. */
export function resolveClaudexModels(env: NodeJS.ProcessEnv = process.env): ClaudexModels {
  const primary = env['ANTHROPIC_MODEL']?.trim() || CLAUDEX_DEFAULT_PRIMARY_MODEL;
  const smallFast = env['ANTHROPIC_SMALL_FAST_MODEL']?.trim() || CLAUDEX_DEFAULT_SMALL_FAST_MODEL;
  return { primary, smallFast };
}

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

/**
 * Names of env vars considered credential-bearing. The whole family is stripped
 * from the composed env so no real Anthropic key, OAuth token, or third-party /
 * cloud credential can reach the local proxy or be used by Claude Code to bypass
 * it. We then re-add ONLY the safe claudex vars (`ANTHROPIC_MODEL`,
 * `_SMALL_FAST_MODEL`, `_BASE_URL`, `_AUTH_TOKEN=unused`). A name-pattern sweep
 * can't miss a specific var a short denylist forgot, while still preserving the
 * arbitrary non-credential env the harness/MCP/hooks require (PATH, HOME, XDG,
 * terminal, proxies, …), which a strict allowlist would fragilely drop.
 *
 * The cloud-provider families (`AWS_*`, `GOOGLE_APPLICATION_CREDENTIALS`,
 * `GOOGLE_CLOUD_*`, `GCP_*`) are included because Claude Code can route to the
 * real Anthropic API via AWS Bedrock / GCP Vertex using the ambient cloud
 * credential chain — a Claude-capable credential that must never survive into a
 * claudex launch. `_KEY$` / `_SECRET` (not just the `_API_KEY$` tail) close the
 * mid-string gap (`STRIPE_SECRET_KEY`, `SSH_PRIVATE_KEY`, `AWS_SECRET_ACCESS_KEY`).
 */
export const CLAUDEX_CREDENTIAL_ENV_RE =
  /^ANTHROPIC_|^CLAUDE_CODE_OAUTH|^AWS_|^GOOGLE_APPLICATION_CREDENTIALS$|^GOOGLE_CLOUD_|^GCP_|_API_?KEY$|_KEY$|_TOKEN$|_SECRET/i;

/**
 * Provider ROUTING switches whose mere PRESENCE (independent of any credential)
 * makes Claude Code bypass `ANTHROPIC_BASE_URL` (the loopback proxy) and talk to
 * the real Anthropic API via Bedrock/Vertex. A name-pattern is the wrong model
 * for a boolean switch, so these are force-deleted by exact name — REGARDLESS of
 * value — after the credential sweep. (REQ 2: isolation must hold for any
 * launching env, including a Bedrock/Vertex-configured enterprise host.)
 */
export const CLAUDEX_FORCED_UNSET_ENV = [
  'CLAUDE_CODE_USE_BEDROCK',
  'CLAUDE_CODE_USE_VERTEX',
  'CLAUDE_CODE_SKIP_BEDROCK_AUTH',
  'CLAUDE_CODE_SKIP_VERTEX_AUTH',
] as const;

export interface BuildClaudexEnvOptions {
  configDir: string;
  models: ClaudexModels;
  /** Override the proxy base URL (defaults to the PR-1 loopback constant). */
  baseUrl?: string;
}

/**
 * Compose the launch env for Claude Code. Returns a FRESH object (never mutates
 * the base env). Strips the entire credential-bearing family AND force-deletes
 * the Bedrock/Vertex routing switches (REQ 2), then sets the isolated config dir
 * and the proxy routing. Claude Code sees only `ANTHROPIC_AUTH_TOKEN=unused`
 * pointed at the loopback proxy; the proxy holds the real OAuth credential, which
 * this module never reads.
 */
export function buildClaudexEnv(
  baseEnv: NodeJS.ProcessEnv,
  opts: BuildClaudexEnvOptions,
): NodeJS.ProcessEnv {
  const env: NodeJS.ProcessEnv = {};
  for (const [name, value] of Object.entries(baseEnv)) {
    if (CLAUDEX_CREDENTIAL_ENV_RE.test(name)) continue; // drop the whole credential family
    env[name] = value;
  }

  // Force-delete routing switches by exact name — their presence (not their
  // value) is what would route Claude Code off the proxy to the real API.
  for (const name of CLAUDEX_FORCED_UNSET_ENV) delete env[name];

  env['CLAUDE_CONFIG_DIR'] = opts.configDir;
  env['ANTHROPIC_BASE_URL'] = opts.baseUrl ?? CLAUDEX_PROXY_URL;
  env['ANTHROPIC_AUTH_TOKEN'] = 'unused';
  env['ANTHROPIC_MODEL'] = opts.models.primary;
  env['ANTHROPIC_SMALL_FAST_MODEL'] = opts.models.smallFast;
  return env;
}

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

/** Console banner shown at launch. Contains no token material by construction. */
export function buildClaudexBanner(models: ClaudexModels): string {
  return [
    '',
    '  ┌─────────────────────────────────────────────────────────────────────┐',
    '  │  ⚠  EXPERIMENTAL — mosaic claudex                                     │',
    '  └─────────────────────────────────────────────────────────────────────┘',
    `  Running GPT models inside the Claude Code harness via claude-code-proxy`,
    `  (ChatGPT-subscription OAuth). This is NOT Anthropic Claude.`,
    `    primary    : ${models.primary}`,
    `    small/fast : ${models.smallFast}`,
    `  Model behavior, tool use, and output quality may differ from Claude.`,
    '',
  ].join('\n');
}

/** Markdown note appended to the composed runtime contract so the model itself
 * knows it is running the EXPERIMENTAL GPT-via-proxy configuration. */
export function buildClaudexContractNote(models: ClaudexModels): string {
  return [
    '# EXPERIMENTAL Runtime — claudex (GPT via claude-code-proxy)',
    '',
    'You are running in Mosaic **claudex** mode: the Claude Code harness is wired to',
    'GPT models through a local `claude-code-proxy` (ChatGPT-subscription OAuth). This',
    "runtime is NOT Anthropic's Claude API and is not Claude.",
    '',
    `- Primary model: \`${models.primary}\``,
    `- Small/fast model: \`${models.smallFast}\``,
    '',
    'Some Claude-specific harness assumptions may not hold under GPT models — verify',
    'tool output carefully. This classification is EXPERIMENTAL and is not intended for',
    'production delivery without explicit operator sign-off.',
  ].join('\n');
}

// ─── Proxy gate ───────────────────────────────────────────────────────────────

export interface ProxyGateResult {
  ok: boolean;
  report: PreflightReport;
  /** Non-sensitive problems suitable for surfacing to the operator. */
  problems: string[];
}

export interface ProxyGateDeps {
  preflight?: () => Promise<PreflightReport>;
  ensureProxy?: () => Promise<EnsureProxyResult>;
  reauth?: () => number;
  log?: (message: string) => void;
}

/**
 * Run the proxy readiness gate: preflight → (device reauth if OAuth needs it) →
 * (start the proxy if nothing trusted is live) → re-preflight. Returns `ok` only
 * when the final preflight passes (binary present, OAuth valid, a TRUSTED-live
 * listener — identity verified by PR-1's `verifyListenerIdentity`). All surfaced
 * problems are non-sensitive (port + verdict only; never a token).
 */
export async function runClaudexProxyGate(deps: ProxyGateDeps = {}): Promise<ProxyGateResult> {
  const preflight = deps.preflight ?? (() => runProxyPreflight());
  const ensureProxy = deps.ensureProxy ?? (() => ensureProxyRunning());
  const reauth = deps.reauth ?? (() => runDeviceReauth());
  const log = deps.log ?? (() => {});

  let report = await preflight();

  // A missing binary is unrecoverable here — don't attempt reauth or a start.
  if (!report.binaryPresent) {
    return { ok: false, report, problems: report.problems };
  }

  if (report.needsReauth) {
    log('claudex: claude-code-proxy OAuth needs re-authentication — starting device flow…');
    const code = reauth();
    if (code !== 0) {
      return {
        ok: false,
        report,
        problems: [...report.problems, 'claudex: device re-authentication did not complete.'],
      };
    }
    report = await preflight();
  }

  if (!report.live) {
    log('claudex: no trusted claude-code-proxy responding — starting it…');
    const started = await ensureProxy();
    if (!started.live) {
      return {
        ok: false,
        report,
        problems: [
          ...report.problems,
          `claudex: could not bring up a trusted claude-code-proxy (${started.method}).`,
        ],
      };
    }
    report = await preflight();
  }

  return { ok: report.ok, report, problems: report.problems };
}

// ─── Launch orchestration ─────────────────────────────────────────────────────

/**
 * The launch.ts-provided seam. Keeps `claudex.ts` free of a circular import back
 * into `launch.ts` while letting the orchestration reuse the harness preflight,
 * the composed runtime contract, and the process-replacing exec.
 */
export interface ClaudexHarnessAdapter {
  /** Runs the Claude-harness preflight (mosaic home, SOUL, `claude` on PATH,
   * sequential-thinking). May terminate the process on a hard failure. */
  harnessPreflight: () => void;
  /** Compose the full Claude runtime contract (== `composeContract('claude')`). */
  composePrompt: () => string;
  /** Replace the current process with `claude` using the composed env. */
  exec: (cmd: string, args: string[], env: NodeJS.ProcessEnv) => void;
}

export interface LaunchClaudexDeps {
  baseEnv?: NodeJS.ProcessEnv;
  proxyGate?: () => Promise<ProxyGateResult>;
  resolveConfigDir?: () => string;
  models?: () => ClaudexModels;
  buildEnv?: (base: NodeJS.ProcessEnv, opts: BuildClaudexEnvOptions) => NodeJS.ProcessEnv;
  log?: (message: string) => void;
  errorLog?: (message: string) => void;
  fail?: (code: number) => never;
}

/**
 * Orchestrate a `mosaic [yolo] claudex` launch. Runs the harness preflight, the
 * proxy gate, composes the isolated env (REQ 1 + REQ 2), appends the EXPERIMENTAL
 * note, and exec's Claude Code. FAIL CLOSED: on any gate failure or guard throw
 * it reports non-sensitive detail and exits WITHOUT reaching exec.
 */
export async function launchClaudex(
  args: string[],
  yolo: boolean,
  adapter: ClaudexHarnessAdapter,
  deps: LaunchClaudexDeps = {},
): Promise<void> {
  const log = deps.log ?? ((m: string) => console.log(m));
  const errorLog = deps.errorLog ?? ((m: string) => console.error(m));
  const fail = deps.fail ?? ((code: number) => process.exit(code));
  const baseEnv = deps.baseEnv ?? process.env;
  const proxyGate = deps.proxyGate ?? (() => runClaudexProxyGate({ log }));
  const resolveConfigDir = deps.resolveConfigDir ?? (() => resolveClaudexConfigDir(baseEnv));
  const models = deps.models ?? (() => resolveClaudexModels(baseEnv));
  const buildEnv = deps.buildEnv ?? buildClaudexEnv;

  try {
    // Harness readiness first (claude on PATH, mosaic home, sequential-thinking).
    adapter.harnessPreflight();

    // Proxy readiness (binary, OAuth, trusted-live listener).
    const gate = await proxyGate();
    if (!gate.ok) {
      errorLog('[mosaic] claudex preflight failed:');
      for (const problem of gate.problems) errorLog(`  - ${problem}`);
      return fail(1);
    }

    // Compose the isolated launch env (guard throws → caught below, fail closed).
    const resolvedModels = models();
    const configDir = resolveConfigDir();
    const env = buildEnv(baseEnv, { configDir, models: resolvedModels });
    const prompt = `${adapter.composePrompt()}\n\n${buildClaudexContractNote(resolvedModels)}`;

    log(buildClaudexBanner(resolvedModels));

    const cliArgs = yolo ? ['--dangerously-skip-permissions'] : [];
    cliArgs.push('--append-system-prompt', prompt, ...args);
    adapter.exec('claude', cliArgs, env);
  } catch (err) {
    errorLog(
      `[mosaic] claudex launch aborted: ${err instanceof Error ? err.message : String(err)}`,
    );
    return fail(1);
  }
}
