/**
 * # cli/caller — the ONE Caller resolver: env-first / TTY-second, fail-closed to non-interactive
 *
 * `resolveCaller()` decides **who is invoking** `kestrel` — an automated **agent** (a coding-agent
 * harness, CI, a pipe) or a **human** — and whether the invocation is eligible for the interactive
 * session (a **confident human only**). It is the detector ADR-0035 §(a)/§(b) fixes and the oversight
 * contract (`docs/design/oversight-protocol-contract.md` §3.1) types as {@link Caller}.
 *
 * ## The resolution ladder (ADR-0035 §b — the load-bearing order)
 *   1. an explicit render flag (`--json` / `--format` / `--agent`) **wins** → non-interactive;
 *   2. else a detected **agent env** → `agent`, **never** interactive (env beats a real TTY: an agent
 *      can hold a PTY, so `isatty` is true — the env is the truth, the TTY is not);
 *   3. else **CI** → `agent`, never interactive;
 *   4. else both stdin+stdout are real TTYs and no agent env / CI / flag → a **confident human** →
 *      interactive-eligible;
 *   5. else (a piped / redirected human) → `human`, non-interactive (static text).
 *
 * **UNDER ANY AMBIGUITY, RESOLVE NON-INTERACTIVE.** The asymmetry is the whole argument (PRD §6): a
 * wrongly-launched TUI **blocks an automated harness forever** — the worst failure in the system;
 * guessing "agent" wrong merely gives a human plain text they can override with a flag. `interactive`
 * is therefore `true` only when *every* condition for a confident human holds.
 *
 * Pure + deterministic: reads its `flags` / `env` / `streams` arguments, never `process` directly
 * (except the thin {@link resolveCallerFromProcess} wrapper), so the whole detection matrix is
 * unit-testable without a terminal. Node-light (no heavy imports): safe on the CLI hot path.
 */

import type { GlobalFlags } from "./context.ts";

/** Who is invoking (oversight contract §3.1 `CallerKind`). */
export type CallerKind = "agent" | "human";
export const CALLER_KINDS = ["agent", "human"] as const satisfies readonly CallerKind[];

/** How the Caller was decided — auditable, never a guess we hide (oversight contract §3.1). */
export type CallerDetection = "flag" | "env" | "ci" | "tty";
export const CALLER_DETECTIONS = ["flag", "env", "ci", "tty"] as const satisfies readonly CallerDetection[];

/**
 * The resolved Caller (oversight contract §3.1). The single source read by the bare-invocation
 * orientation (and, when the platform lands, by platform-bound telemetry) so rendering and analytics
 * can never disagree about who is calling.
 *
 * FAIL-CLOSED: under ANY ambiguity `interactive` is `false`.
 */
export interface Caller {
  readonly kind: CallerKind;
  /** The detected harness / CI system, when known (e.g. `"CLAUDECODE"`, `"CI"`). `null` ⇒ unknown,
   *  never invented. */
  readonly harness: string | null;
  readonly detectedBy: CallerDetection;
  /** Interactive session eligible: a CONFIDENT human only — both TTYs, no agent env, no CI, no flag. */
  readonly interactive: boolean;
}

/**
 * The agent env-var table (ADR-0035 §a, PRD §0.2). An agent harness sets one of these; they are
 * checked BEFORE `isatty` because an agent can hold a PTY (where `isatty` is true) and the env is the
 * ground truth the TTY would lie about. `CODEX_*` is a PREFIX (any `CODEX_…` var). `KESTREL_AGENT` is
 * this repo's own pre-existing agent signal (`src/cli/context.ts` `resolveOutputCtx`), folded in so
 * the two resolvers agree on it; every other marker is from the ADR table verbatim.
 */
export const AGENT_ENV_MARKERS = [
  "CLAUDECODE",
  "CLAUDE_CODE_CHILD_SESSION",
  "CURSOR_AGENT",
  "GEMINI_CLI",
  "OPENCODE",
  "AI_AGENT",
  "AGENT",
  "KESTREL_AGENT",
] as const;

/** The `CODEX_*` prefix (ADR table) — any environment variable whose name starts with this. */
export const AGENT_ENV_PREFIX = "CODEX_" as const;

/** CI markers. `CI` is canonical (ADR §a); the others are this repo's existing set (`resolveOutputCtx`). */
export const CI_MARKERS = ["CI", "GITHUB_ACTIONS", "BUILDKITE", "GITLAB_CI"] as const;

interface Env {
  readonly [k: string]: string | undefined;
}

interface Streams {
  readonly stdinTTY: boolean;
  readonly stdoutTTY: boolean;
}

/** A var counts as SET when present and non-empty — a bare `FOO=` never trips detection. */
function isSet(v: string | undefined): boolean {
  return v !== undefined && v !== "";
}

/** The FIRST matching agent marker (exact table then the `CODEX_*` prefix), or `null`. */
export function detectAgentEnv(env: Env): string | null {
  for (const key of AGENT_ENV_MARKERS) {
    if (isSet(env[key])) return key;
  }
  for (const key of Object.keys(env)) {
    if (key.startsWith(AGENT_ENV_PREFIX) && isSet(env[key])) return key;
  }
  return null;
}

/** The FIRST matching CI marker, or `null`. */
export function detectCI(env: Env): string | null {
  for (const key of CI_MARKERS) {
    if (isSet(env[key])) return key;
  }
  return null;
}

/** True iff a render flag was given explicitly — `--json`, `--format <m>`, or `--agent`. */
function hasRenderFlag(flags: GlobalFlags): boolean {
  return flags.json === true || flags.format !== undefined || flags.agent === true;
}

/**
 * Resolve the Caller from parsed flags, the environment, and the two stream TTY bits. Pure and total.
 * See the module header for the ladder; the asymmetry (fail-closed to non-interactive) is enforced by
 * the single `interactive` conjunction at the end — it can only be `true` for a confident human.
 */
export function resolveCaller(flags: GlobalFlags, env: Env, s: Streams): Caller {
  const agentEnv = detectAgentEnv(env);
  const ci = detectCI(env);
  const renderFlag = hasRenderFlag(flags);

  // --- kind + detectedBy (env-first, TTY-second) ---
  let kind: CallerKind;
  let harness: string | null;
  let detectedBy: CallerDetection;
  if (flags.agent === true) {
    // An explicit `--agent` is an unambiguous machine request.
    kind = "agent";
    harness = null;
    detectedBy = "flag";
  } else if (agentEnv !== null) {
    // ENV BEATS TTY: an agent holding a PTY is still an agent.
    kind = "agent";
    harness = agentEnv;
    detectedBy = "env";
  } else if (ci !== null) {
    kind = "agent";
    harness = ci;
    detectedBy = "ci";
  } else if (renderFlag) {
    // A human (or agent) forcing `--json`/`--format`: machine output, but no agent env to attribute.
    kind = "human";
    harness = null;
    detectedBy = "flag";
  } else {
    // Fell through to the terminal probe.
    kind = "human";
    harness = null;
    detectedBy = "tty";
  }

  // --- interactive: a CONFIDENT human ONLY (fail-closed) ---
  // Every clause must hold: no forcing flag, no agent env, no CI, and BOTH streams real TTYs. Any
  // ambiguity leaves at least one clause false ⇒ non-interactive.
  const interactive = kind === "human" && !renderFlag && agentEnv === null && ci === null && s.stdinTTY && s.stdoutTTY;

  return Object.freeze({ kind, harness, detectedBy, interactive });
}

/** Resolve the live Caller from `process` — the one place that touches the real terminal + env. */
export function resolveCallerFromProcess(flags: GlobalFlags): Caller {
  return resolveCaller(flags, process.env as Env, {
    stdinTTY: Boolean(process.stdin.isTTY),
    stdoutTTY: Boolean(process.stdout.isTTY),
  });
}
