import * as fs from "node:fs";
import * as os from "node:os";
import * as path from "node:path";

import type { PlatformId } from "../adapters/types.js";
import { workspaceEnvVarsFor } from "../adapters/detect.js";

/**
 * Universal escape hatch. NEVER appears in any platform's foreignWorkspaceEnv()
 * (because it isn't registered in PLATFORM_ENV_VARS), so it survives strict
 * mode and bridge env scrubs. Documented as the cross-strict user override
 * for every adapter (set in `~/.<host>/mcp.json` env when nothing else works).
 */
const UNIVERSAL_WORKSPACE_ENV = ["QUIET_CONTEXT_PROJECT_DIR"] as const;

/**
 * Frozen legacy candidate list — preserves bit-for-bit behavior of every
 * non-strict caller (`start.mjs` and any caller that doesn't pass
 * `strictPlatform`). Order is locked for semver compatibility.
 *
 * If a new adapter is added, DO NOT add its workspace var here — register it
 * in `PLATFORM_ENV_VARS` and let strict callers pick it up via
 * `workspaceEnvVarsFor(platform)`. Strict mode is the default forward path.
 */
const LEGACY_NON_STRICT_CANDIDATES: readonly string[] = [
  "CLAUDE_PROJECT_DIR",
  "GEMINI_PROJECT_DIR",
  "VSCODE_CWD",
  "OPENCODE_PROJECT_DIR",
  "PI_PROJECT_DIR",
  "IDEA_INITIAL_DIRECTORY",
  "CURSOR_CWD",
  "QUIET_CONTEXT_PROJECT_DIR",
];

/**
 * Project-dir resolution helpers — shared between `start.mjs` (the MCP entry
 * point) and `src/server.ts getProjectDir()` (the consumer).
 *
 * Background: when Claude Code runs `/ctx-upgrade`, it kills + respawns the
 * MCP server. The respawn happens with `cwd` set to the plugin install
 * directory (`~/.claude/plugins/cache/context-mode/context-mode/<version>/`).
 * The legacy `start.mjs` then set `CLAUDE_PROJECT_DIR = originalCwd`, which
 * poisoned every downstream `ctx_stats` / SessionDB / hash computation —
 * sessions silently re-rooted under the plugin install path.
 *
 * Defense-in-depth fix (v1.0.113):
 *   - `start.mjs` calls `isPluginInstallPath(originalCwd)` and skips the env
 *     auto-set when true (no poisoning at the source).
 *   - `getProjectDir()` calls `resolveProjectDir(...)` which rejects plugin-
 *     pathed env vars and the plugin cwd, preferring `process.env.PWD`
 *     (shell-set, survives `process.chdir`) before falling back.
 */

/**
 * Detect whether a path lives inside an agent plugin install tree —
 * specifically `<home>/.claude/plugins/cache/<plugin>/<plugin>/<version>/`,
 * `<home>/.codex/plugins/cache/<plugin>/<plugin>/<version>/`, or the
 * marketplace mirror under `<home>/.{claude,codex}/plugins/marketplaces/...`.
 *
 * Cross-OS: matches both POSIX (`/`) and Windows (`\`) path separators.
 * Independent of `home` location — we only care about the agent plugin
 * suffix pattern.
 */
export function isPluginInstallPath(p: string): boolean {
  if (!p) return false;
  return /[/\\]\.(claude|codex)[/\\]plugins[/\\](cache|marketplaces)[/\\]/.test(p);
}

/**
 * Read the per-session project dir from Claude Code's transcript files.
 *
 * Claude Code writes session transcripts under
 * `~/.claude/projects/<encoded-cwd>/<session-id>.jsonl`. Each line is a JSON
 * event; an early line (typically line 2) carries a `cwd` field with the
 * literal project directory the session is running against. The encoded dir
 * name itself is lossy (`/` and `.` both become `-`), so we read the JSONL.
 *
 * This is the strongest available signal when Claude Code does NOT propagate
 * `CLAUDE_PROJECT_DIR` to the spawned MCP env (the common case when Claude
 * Code is launched from the desktop app rather than `cd <project> && claude`).
 *
 * Returns `undefined` when no transcript exists, the projects dir is empty,
 * or no transcript carries a `cwd` field — caller falls through.
 *
 * Multi-window safety: the most-recently-modified jsonl wins. When the user
 * actively talks to one Claude Code window, that window's transcript is the
 * one being written to RIGHT NOW, so its mtime is freshest. Other windows'
 * transcripts have older mtimes and are correctly ignored.
 */
export function resolveProjectDirFromTranscript(opts: {
  projectsRoot: string;
  /**
   * Optional freshness guard. Claude Code updates the active transcript while
   * the session is being used; stale transcripts from previous days must not
   * become a global project-dir signal for other hosts that merely have
   * ~/.claude on disk.
   */
  maxAgeMs?: number;
  /** Test seam for maxAgeMs. Defaults to Date.now(). */
  nowMs?: number;
}): string | undefined {
  if (!fs.existsSync(opts.projectsRoot)) return undefined;

  let bestPath: string | undefined;
  let bestMtime = 0;
  try {
    for (const dir of fs.readdirSync(opts.projectsRoot)) {
      const dirPath = path.join(opts.projectsRoot, dir);
      let stat;
      try { stat = fs.statSync(dirPath); } catch { continue; }
      if (!stat.isDirectory()) continue;
      let files;
      try { files = fs.readdirSync(dirPath); } catch { continue; }
      for (const f of files) {
        if (!f.endsWith(".jsonl")) continue;
        const fp = path.join(dirPath, f);
        try {
          const m = fs.statSync(fp).mtimeMs;
          if (m > bestMtime) { bestMtime = m; bestPath = fp; }
        } catch { /* skip */ }
      }
    }
  } catch { return undefined; }

  if (!bestPath) return undefined;
  if (typeof opts.maxAgeMs === "number") {
    const nowMs = opts.nowMs ?? Date.now();
    if (nowMs - bestMtime > opts.maxAgeMs) return undefined;
  }

  // Read first ~10 lines until we find a cwd field. The jsonl is
  // append-only and can be huge (60+ MB on long sessions) — never load it
  // into memory; stream a small head buffer.
  try {
    const fd = fs.openSync(bestPath, "r");
    try {
      const buf = Buffer.alloc(8192);
      const bytes = fs.readSync(fd, buf, 0, buf.length, 0);
      const text = buf.subarray(0, bytes).toString("utf-8");
      for (const line of text.split("\n").slice(0, 10)) {
        if (!line.trim()) continue;
        try {
          const obj = JSON.parse(line) as { cwd?: unknown };
          if (typeof obj.cwd === "string" && obj.cwd.length > 0) return obj.cwd;
        } catch { /* skip malformed line */ }
      }
    } finally {
      fs.closeSync(fd);
    }
  } catch { /* file vanished mid-read */ }

  return undefined;
}

/**
 * Issue #45 / c4529042182 — recover the project-cwd from a Codex CLI
 * session log when the spawned MCP child inherits a non-project cwd
 * (e.g. $HOME when Codex was launched from anywhere outside the project).
 *
 * Codex writes its session transcripts to either
 * `${CODEX_HOME ?? ~/.codex}/sessions/<uuid>.jsonl` (CLI) or a dated desktop
 * layout such as
 * `${CODEX_HOME ?? ~/.codex}/sessions/YYYY/MM/DD/rollout-*.jsonl`.
 * The cwd appears on `meta.cwd` for the CLI shape and on
 * `payload.cwd` in `type: "session_meta"` records for Codex Desktop. Codex
 * publishes NO workspace env var to its child MCP processes — so unlike
 * Claude/Pi/Cursor, we have no env signal at all. The session log is the
 * strongest available signal.
 *
 * Mirror of `resolveProjectDirFromTranscript` for Claude Code; differences:
 *   • Sessions may live flat or in a dated hierarchy (no per-project encoded
 *     subdir like Claude's `~/.claude/projects/<encoded>/`).
 *   • The cwd is nested on `meta.cwd` or `payload.cwd`, not top-level `cwd`.
 *
 * Returns `null` when:
 *   • `codexHome` or its `sessions/` subdir does not exist.
 *   • No `.jsonl` files exist or none has a parseable cwd string.
 *   • The newest log is older than `transcriptMaxAgeMs` (multi-window guard).
 *   • The resolved cwd points at a plugin install path (poisoned).
 */
export function resolveCodexSessionCwd(opts?: {
  /** Defaults to `process.env.CODEX_HOME ?? path.join(os.homedir(), ".codex")`. */
  codexHome?: string;
  /**
   * Optional freshness guard — Codex appends to the active log while the
   * session is running, so a stale log from days ago must not become a
   * global project-dir signal.
   */
  transcriptMaxAgeMs?: number;
  /** Test seam for transcriptMaxAgeMs. Defaults to Date.now(). */
  now?: number;
}): string | null {
  const codexHome =
    opts?.codexHome ?? process.env.CODEX_HOME ?? path.join(os.homedir(), ".codex");
  const sessionsDir = path.join(codexHome, "sessions");
  if (!fs.existsSync(sessionsDir)) return null;

  const MAX_SCAN_DEPTH = 4; // sessions/YYYY/MM/DD/<file>.jsonl plus one spare.
  const MAX_SCAN_ENTRIES = 10_000;
  let visitedEntries = 0;
  let bestPath: string | undefined;
  let bestMtime = 0;
  const visit = (dir: string, depth: number) => {
    if (visitedEntries >= MAX_SCAN_ENTRIES) return;
    let entries: string[];
    try { entries = fs.readdirSync(dir); } catch { return; }
    entries.sort().reverse();
    for (const entry of entries) {
      if (visitedEntries >= MAX_SCAN_ENTRIES) return;
      visitedEntries++;
      const fp = path.join(dir, entry);
      let stat;
      try { stat = fs.statSync(fp); } catch { continue; }
      if (stat.isDirectory()) {
        if (depth < MAX_SCAN_DEPTH) visit(fp, depth + 1);
        continue;
      }
      if (!stat.isFile() || !entry.endsWith(".jsonl")) continue;
      const m = stat.mtimeMs;
      if (m > bestMtime) { bestMtime = m; bestPath = fp; }
    }
  };
  try {
    visit(sessionsDir, 0);
  } catch { return null; }

  if (!bestPath) return null;
  if (typeof opts?.transcriptMaxAgeMs === "number") {
    const nowMs = opts.now ?? Date.now();
    if (nowMs - bestMtime > opts.transcriptMaxAgeMs) return null;
  }

  // Read a bounded head chunk. Codex Desktop's first session_meta line can be
  // larger than Claude/Codex CLI metadata because it includes dynamic tool and
  // instruction fields, but the full transcript can still be tens of MB.
  try {
    const fd = fs.openSync(bestPath, "r");
    try {
      const buf = Buffer.alloc(1024 * 1024);
      const bytes = fs.readSync(fd, buf, 0, buf.length, 0);
      const text = buf.subarray(0, bytes).toString("utf-8");
      for (const line of text.split("\n").slice(0, 10)) {
        if (!line.trim()) continue;
        try {
          const obj = JSON.parse(line) as {
            type?: unknown;
            meta?: { cwd?: unknown };
            payload?: { cwd?: unknown };
          };
          const cwd = obj?.meta?.cwd ??
            (obj?.type === "session_meta" ? obj?.payload?.cwd : undefined);
          if (typeof cwd !== "string" || cwd.length === 0) continue;
          if (isPluginInstallPath(cwd)) return null;
          return cwd;
        } catch { return null; /* malformed session metadata line */ }
      }
    } finally {
      fs.closeSync(fd);
    }
  } catch { return null; /* file vanished mid-read */ }
  return null;
}

/**
 * Pure project-dir resolver. Mirror of the env-var chain inside
 * `src/server.ts getProjectDir()`, but takes its inputs explicitly so the
 * resolver can be exercised under test without process-level mutation.
 *
 * Resolution order:
 *   1. Adapter-priority env vars (CLAUDE / GEMINI / VSCODE / OPENCODE / PI /
 *      IDEA / QUIET_CONTEXT) — first non-empty AND non-plugin-path wins.
 *   2. Claude Code transcript heuristic — read `cwd` from the most-recently-
 *      modified `~/.claude/projects/<encoded>/<session>.jsonl`. This is the
 *      most reliable signal when Claude Code launched MCP from a non-project
 *      cwd (desktop-app launch, `/ctx-upgrade` respawn, etc.).
 *   3. `process.env.PWD` — shell-set, NOT updated by `process.chdir()`, so
 *      it survives the `start.mjs` chdir into the plugin dir. Skipped if
 *      it too points at a plugin install path.
 *   4. `cwd` — last resort. Returned even if it is a plugin path; the
 *      caller is responsible for rendering a graceful "no project context"
 *      message rather than panicking. Keeping the function total preserves
 *      operation of project-independent tools (sandbox execute, fetch).
 */
export function resolveProjectDir(opts: {
  env: Record<string, string | undefined>;
  cwd: string;
  pwd: string | undefined;
  /** Optional override; production code passes `~/.claude/projects`. */
  transcriptsRoot?: string;
  /** Optional freshness guard for Claude Code transcript project recovery. */
  transcriptMaxAgeMs?: number;
  /** Test seam for transcriptMaxAgeMs. Defaults to Date.now(). */
  nowMs?: number;
  /**
   * Issue #545 — opt-in tightening. When set, the candidate list is built
   * algorithmically from `workspaceEnvVarsFor(strictPlatform)` plus the
   * universal escape hatch. Foreign workspace vars (e.g. CLAUDE_PROJECT_DIR
   * leaked into Pi's MCP child env) cannot win, regardless of cascade order.
   *
   * When `undefined`, the legacy literal candidate order is used (semver lock
   * for `start.mjs` and any non-strict consumer).
   */
  strictPlatform?: PlatformId;
  /**
   * Issue #45 — override `${CODEX_HOME ?? ~/.codex}` for tests. When
   * `strictPlatform === "codex"` and the env cascade yields nothing, the
   * resolver reads `meta.cwd` from the newest session.jsonl under
   * `${codexHome}/sessions/`.
   */
  codexHome?: string;
}): string {
  const {
    env, cwd, pwd, transcriptsRoot, transcriptMaxAgeMs, nowMs, strictPlatform, codexHome,
  } = opts;
  // Build candidate list. Strict path: own workspace vars + universal escape
  // hatch — NO foreign workspace vars, in any order, can win. Non-strict
  // path: frozen legacy literal order for backwards compatibility.
  const candidateVars: readonly string[] = strictPlatform
    ? [...workspaceEnvVarsFor(strictPlatform), ...UNIVERSAL_WORKSPACE_ENV]
    : LEGACY_NON_STRICT_CANDIDATES;
  for (const name of candidateVars) {
    const v = env[name];
    if (v && !isPluginInstallPath(v)) return v;
  }
  if (transcriptsRoot) {
    const fromTranscript = resolveProjectDirFromTranscript({
      projectsRoot: transcriptsRoot,
      maxAgeMs: transcriptMaxAgeMs,
      nowMs,
    });
    if (fromTranscript && !isPluginInstallPath(fromTranscript)) return fromTranscript;
  }
  // Issue #45 — Codex has no workspace env var, so when running under
  // strictPlatform="codex" we fall back to the session-log heuristic
  // between env and PWD. Non-codex platforms skip this branch entirely.
  if (strictPlatform === "codex") {
    const fromCodex = resolveCodexSessionCwd({
      codexHome,
      transcriptMaxAgeMs,
      now: nowMs,
    });
    if (fromCodex) return fromCodex;
  }
  if (pwd && !isPluginInstallPath(pwd)) return pwd;
  return cwd;
}
