/**
 * prefix.ts — split opencode's system message into a stable (cacheable)
 * head and a volatile tail.
 *
 * Ground truth (measured 2026-10-02, opencode 1.18.34, captured body in
 * testdata/captured_body.json, repo root = dev/harness/opencode/cache):
 *
 *   opencode sends ONE system message (12,251 chars) built by
 *   LLMRequestPrep.prepare as
 *
 *     [agent prompt]
 *     [provider/model prompt]  "You are powered by the model named …"
 *     [environment]            <env> cwd / root / git / platform / date
 *     [AGENTS.md]              "Instructions from: <path>" + content
 *     [MCP]                    <mcp_instructions> … per-server text
 *     [skills]                 "Skills provide specialized instructions…"
 *
 *   followed by tools[] and then the conversation. Measured on the
 *   tools-first chat_template_3.8.jinja:
 *
 *     tools block            5,806 tok   (67% of the request)
 *     full system string     2,813 tok
 *     full request           8,619 tok
 *     prefix up to <mcp_instructions>  8,003 tok  → 7,991 tok of real
 *     token-prefix overlap with the live request (92.7% of the request)
 *
 *   The template renders tools BEFORE the system content, so the tools
 *   array is part of the cached prefix and must be sent verbatim by the
 *   warm-up request (see warmup.ts).
 *
 * Volatility ranking — this is the one thing that differs from pi:
 *
 *   - <env> carries the working directory and TODAY'S DATE: stable within
 *     a project-day, different per project. Cutting here yields a prefix
 *     that is IDENTICAL for every project and every day (pi parity: pi
 *     also cut before its per-project context, which is why its cached
 *     prefix was shared across projA and projB).
 *   - "Instructions from:" — the AGENTS.md/CLAUDE.md block, per project.
 *   - <mcp_instructions> contains live session state ("0 calls | 0 tok
 *     saved", the active root). It changes on EVERY turn, so it must
 *     always be excluded or the fingerprint would miss forever.
 *   - the skills list changes when skills are added/removed.
 *
 * Measured trade-off on the tools-first chat_template_3.8.jinja:
 *
 *   cut at <env>              7,666 tok  — ONE fingerprint for all
 *                                        projects/days; ~950 tokens
 *                                        re-prefilled per session start
 *   cut at <mcp_instructions> 8,003 tok  — per-project+day fingerprint
 *                                        (a new project or a new day
 *                                        costs a ~35 s cold rebuild);
 *                                        ~610 tokens re-prefilled
 *
 * Default: the earliest marker in BOUNDARY_PRIORITY order that is
 * present (i.e. <env> when opencode emits it) — maximum cache hit rate,
 * which is what pi chose. Set OC_PREFIX_CACHE_BOUNDARY=mcp to trade
 * cache-hit rate for ~340 fewer re-prefilled tokens.
 */

export interface StablePrefix {
  /** Stable content, trailing whitespace stripped. */
  text: string;
  /** Which boundary ended the stable region. */
  boundary: BoundaryName | null;
  /** Index of the boundary marker in the original content. */
  boundaryIndex: number;
  ok: boolean;
  reason?: string;
}

export type BoundaryName = "env" | "instructions" | "mcp" | "skills";

/** Boundary markers in cut-priority order: the first one PRESENT wins
 *  (not the earliest position — they happen to coincide in opencode's
 *  layout, but priority is the contract). Keep in sync with the layout
 *  documented above; if opencode renames them, extraction fails open
 *  (logged) and the cache is simply unused — the safe direction. */
const BOUNDARY_PRIORITY: Array<{ name: BoundaryName; marker: string }> = [
  { name: "env", marker: "\n<env>" },
  { name: "instructions", marker: "\nInstructions from:" },
  { name: "mcp", marker: "\n<mcp_instructions>" },
  { name: "skills", marker: "\nSkills provide specialized" },
];

/** Deployment override: cut at the named marker instead of the first
 *  present one ("env" default, "mcp" for max per-project reuse). */
function boundaryOrder(): Array<{ name: BoundaryName; marker: string }> {
  const want = (process.env.OC_PREFIX_CACHE_BOUNDARY ?? "").trim();
  if (!want) return BOUNDARY_PRIORITY;
  const rest = BOUNDARY_PRIORITY.filter((b) => b.name !== want);
  const picked = BOUNDARY_PRIORITY.filter((b) => b.name === want);
  return picked.length ? [...picked, ...rest] : BOUNDARY_PRIORITY;
}

/** A stable head shorter than this is not worth a checkpoint file
 *  (the .bin + .ckpt pair costs hundreds of MB). Fail open. */
export const MIN_STABLE_CHARS = 2000;

export function extractStablePrefix(content: string): StablePrefix {
  let idx = -1;
  let boundary: BoundaryName | null = null;
  for (const { name, marker } of boundaryOrder()) {
    const i = content.indexOf(marker);
    if (i !== -1) {
      idx = i;
      boundary = name;
      break;
    }
  }

  if (idx === -1) {
    return {
      text: "",
      boundary: null,
      boundaryIndex: -1,
      ok: false,
      reason: "no volatile-section marker found in system content",
    };
  }

  const text = content.slice(0, idx).replace(/\s+$/, "");
  if (text.length < MIN_STABLE_CHARS) {
    return {
      text,
      boundary,
      boundaryIndex: idx,
      ok: false,
      reason: `stable head only ${text.length} chars (< ${MIN_STABLE_CHARS})`,
    };
  }

  return { text, boundary, boundaryIndex: idx, ok: true };
}

/** Locate opencode's system message in a provider request body.
 *  opencode always sends exactly one, as role "system" (role
 *  "developer" is accepted for robustness / other frontends). */
export function findSystemMessage(
  messages: Array<{ role?: string; content?: unknown }>,
): { role: string; content: string } | null {
  for (const m of messages) {
    if ((m.role === "system" || m.role === "developer") && typeof m.content === "string") {
      return { role: m.role, content: m.content };
    }
  }
  return null;
}
