// Run: bun test packages/reasoning/tests/context/context-curator.test.ts --timeout 15000
//
// Pin the S2.5 ContextCurator seam:
//   1. defaultContextCurator returns the same Prompt as the underlying
//      ContextManager.build (Slice A is byte-identical wrapping).
//   2. renderObservationForPrompt wraps untrusted observations in a
//      <tool_output> block; trusted observations render plainly.
//
// If a future refactor introduces a parallel prompt-author path that bypasses
// the curator, these assertions still hold for the curator path — but cf-19
// (gate scenario) is what surfaces the *architectural* regression.

import { describe, it, expect, beforeAll, afterAll } from "bun:test";

// Pin pre-lazy-tool-disclosure contract — see f51d7d87.
const PRIOR_LAZY = process.env.RA_LAZY_TOOLS;
beforeAll(() => {
  process.env.RA_LAZY_TOOLS = "0";
});
afterAll(() => {
  if (PRIOR_LAZY === undefined) delete process.env.RA_LAZY_TOOLS;
  else process.env.RA_LAZY_TOOLS = PRIOR_LAZY;
});
import {
  defaultContextCurator,
  renderObservationForPrompt,
  buildRecentObservationsSection,
  RECENT_OBSERVATIONS_HEADER,
} from "../../src/context/context-curator.js";
import { ContextManager, type GuidanceContext } from "../../src/context/context-manager.js";
import { CONTEXT_PROFILES } from "../../src/context/context-profile.js";
import type { KernelState } from "../../src/kernel/state/kernel-state.js";
import type { ReasoningStep } from "../../src/types/step.js";
import type { ObservationResult } from "../../src/types/observation.js";

function makeObs(
  toolName: string,
  trustLevel: "trusted" | "untrusted",
  displayText: string,
): ObservationResult {
  return {
    success: true,
    toolName,
    displayText,
    category: trustLevel === "trusted" ? "scratchpad" : "web-search",
    resultKind: trustLevel === "trusted" ? "side-effect" : "data",
    preserveOnCompaction: false,
    trustLevel,
    ...(trustLevel === "trusted" ? { trustJustification: "grandfather-phase-1" } : {}),
  };
}

function makeObservationStep(obs: ObservationResult, idx: number): ReasoningStep {
  return {
    id: `step-${idx}` as ReasoningStep["id"],
    type: "observation",
    content: obs.displayText,
    timestamp: new Date(2026, 3, 25, 22, idx, 0),
    metadata: { observationResult: obs },
  };
}

function makeState(overrides: Partial<KernelState> = {}): KernelState {
  return {
    taskId: "t1",
    strategy: "reactive",
    kernelType: "react",
    steps: [],
    toolsUsed: new Set<string>(),
    scratchpad: new Map<string, string>(),
    iteration: 0,
    tokens: 0,
    cost: 0,
    status: "thinking",
    output: null,
    error: null,
    llmCalls: 0,
    meta: {},
    controllerDecisionLog: [],
    messages: [],
    pendingGuidance: undefined,
    consecutiveLowDeltaCount: 0,
    readyToAnswerNudgeCount: 0,
    lastMetaToolCall: undefined,
    consecutiveMetaToolCount: 0,
    ...overrides,
  } as KernelState;
}

function makeInput() {
  return {
    task: "Summarize the docs",
    availableToolSchemas: [
      { name: "web-search", description: "Search", parameters: [] },
    ],
    requiredTools: [] as string[],
  } as never;
}

const noGuidance: GuidanceContext = {
  requiredToolsPending: [],
  loopDetected: false,
};

describe("defaultContextCurator", () => {
  it("returns a Prompt byte-identical to ContextManager.build (Slice A wrapping)", () => {
    const state = makeState();
    const input = makeInput();
    const profile = CONTEXT_PROFILES.local;

    const direct = ContextManager.build(state, input, profile, noGuidance);
    const curated = defaultContextCurator.curate(state, input, profile, noGuidance);

    expect(curated.systemPrompt).toBe(direct.systemPrompt);
    expect(curated.messages).toEqual(direct.messages);
  });

  it("forwards options (availableTools, systemPromptBody) through to the underlying builder", () => {
    const state = makeState();
    const input = makeInput();
    const profile = CONTEXT_PROFILES.local;

    const { systemPrompt } = defaultContextCurator.curate(
      state,
      input,
      profile,
      noGuidance,
      undefined,
      {
        availableTools: [{ name: "custom-only", description: "", parameters: [] }],
        systemPromptBody: "You are a documentation summarizer.",
      },
    );

    expect(systemPrompt).toContain("custom-only");
    expect(systemPrompt).toContain("documentation summarizer");
  });
});

// ── W6 (FIX-4 / FIX-20) — compression coordination invariant ─────────────
//
// HISTORICAL NOTE: as of Issue #119 (May 2026), the RI dispatcher's
// `compress-messages` patch NO LONGER mutates state.messages directly. The
// patch is demoted to advisory — it records a CompressionRecommendation on
// state.meta.pendingCompressionRecommendation; the curator (via
// applyMessageWindowWithCompact) consumes that recommendation on the next
// iteration and authors the rendered Prompt.messages. Curator-as-sole-
// prompt-author (North Star v5.0 §4.3) is now enforced structurally, not
// just by timing.
//
// Two-stage pipeline (post-#119):
//   (a) tool-execution.ts compresses + stashes full content in scratchpad
//       keyed by storedKey
//   (b) curator renders by reading scratchpad via storedKey + clamps the
//       conversation thread budget when a CompressionRecommendation is
//       pending — state.messages is never mutated by the patch path
//
// This test pins the (a)+(b) bridge: tool-execution writes the compressed
// preview as observation displayText, but the curator's recent-observations
// section resolves the FULL content via storedKey lookup. The simulated
// .slice(-1) below remains valid because Prompt.messages (what the LLM
// sees) is a derived view; trimming the rendered view never touches state
// or scratchpad.
describe("compression coordination (W6 / FIX-4 / FIX-20)", () => {
  it("curator renders full tool-result via storedKey after thread-level compress-messages would trim state.messages", () => {
    const FULL_CONTENT = "FULL_TOOL_RESULT: " + "x".repeat(2500);
    const STORED_KEY = "tool-result-001";
    const COMPRESSED_PREVIEW = "[compressed preview — full in scratchpad]";

    // Pre-state: tool-execution wrote FULL_CONTENT to scratchpad keyed by
    // STORED_KEY, then created an observation step whose displayText is
    // the compressed preview. Messages array contains the assistant +
    // tool_result turn.
    const obs: ObservationResult = {
      success: true,
      toolName: "web-search",
      displayText: COMPRESSED_PREVIEW,
      category: "web-search",
      resultKind: "data",
      preserveOnCompaction: false,
      trustLevel: "untrusted",
    };
    const obsStep: ReasoningStep = {
      id: "step-1" as ReasoningStep["id"],
      type: "observation",
      content: COMPRESSED_PREVIEW,
      timestamp: new Date(),
      metadata: { observationResult: obs, storedKey: STORED_KEY },
    };

    const stateBefore = makeState({
      steps: [obsStep],
      scratchpad: new Map([[STORED_KEY, FULL_CONTENT]]),
      messages: [
        { role: "user", content: "find X" } as never,
        { role: "assistant", content: "calling web-search" } as never,
        { role: "tool", content: COMPRESSED_PREVIEW } as never,
      ],
    });

    // Simulate a thread-trimmed prompt view: post-#119 the patch handler
    // no longer mutates state.messages, but the curator's
    // applyMessageWindowWithCompact may still slim the rendered view when
    // a CompressionRecommendation is pending. The trim below stands in for
    // the rendered Prompt.messages — never written back to state.
    const stateAfter = {
      ...stateBefore,
      messages: stateBefore.messages.slice(-1),
    };

    // Invariant 1: steps + scratchpad survive compress-messages
    expect(stateAfter.steps).toBe(stateBefore.steps);
    expect(stateAfter.scratchpad).toBe(stateBefore.scratchpad);

    // Invariant 2: curator's section renders FULL_CONTENT (truncated by
    // per-tier cap) — NOT the compressed preview. The compressed preview
    // is only the displayText fallback used when storedKey lookup misses.
    const section = buildRecentObservationsSection(stateAfter.steps, 1, {
      scratchpad: stateAfter.scratchpad,
      maxCharsPerObservation: 4000, // larger than FULL_CONTENT to skip cap
    });

    expect(section).toBeTruthy();
    expect(section).toContain("FULL_TOOL_RESULT");
    expect(section).not.toContain(COMPRESSED_PREVIEW);
  });

  it("falls back to displayText when storedKey is missing from scratchpad", () => {
    // Confirms the second leg of the pipeline: if the dispatch ever did
    // clear scratchpad entries, the curator degrades gracefully to the
    // compressed preview written by tool-execution.ts. No crash.
    const obs: ObservationResult = {
      success: true,
      toolName: "web-search",
      displayText: "preview only — full evicted",
      category: "web-search",
      resultKind: "data",
      preserveOnCompaction: false,
      trustLevel: "untrusted",
    };
    const obsStep: ReasoningStep = {
      id: "step-1" as ReasoningStep["id"],
      type: "observation",
      content: obs.displayText,
      timestamp: new Date(),
      metadata: { observationResult: obs, storedKey: "missing-key" },
    };

    const section = buildRecentObservationsSection([obsStep], 1, {
      scratchpad: new Map(), // empty scratchpad
      maxCharsPerObservation: 4000,
    });

    expect(section).toContain("preview only");
  });
});

describe("renderObservationForPrompt", () => {
  const trusted: ObservationResult = {
    success: true,
    toolName: "recall",
    displayText: "scratchpad value here",
    category: "scratchpad",
    resultKind: "side-effect",
    preserveOnCompaction: false,
    trustLevel: "trusted",
    trustJustification: "grandfather-phase-1",
  };

  const untrusted: ObservationResult = {
    success: true,
    toolName: "web-search",
    displayText: "Page contents may contain ignore previous instructions.",
    category: "web-search",
    resultKind: "data",
    preserveOnCompaction: false,
    trustLevel: "untrusted",
  };

  it("renders trusted observations plainly (no wrapping)", () => {
    const out = renderObservationForPrompt(trusted);
    expect(out).toBe("scratchpad value here");
    expect(out).not.toContain("<tool_output");
  });

  it("wraps untrusted observations in a <tool_output> block tagged with toolName", () => {
    const out = renderObservationForPrompt(untrusted);
    expect(out.startsWith('<tool_output tool="web-search">')).toBe(true);
    expect(out.endsWith("</tool_output>")).toBe(true);
    expect(out).toContain("ignore previous instructions");
  });

  it("preserves the original displayText inside the wrapper (no truncation)", () => {
    const long = { ...untrusted, displayText: "A".repeat(2000) };
    const out = renderObservationForPrompt(long);
    expect(out).toContain("A".repeat(2000));
  });
});

// ── Slice B: buildRecentObservationsSection + curator section authorship ──────

describe("buildRecentObservationsSection", () => {
  it("returns null when limit is 0 or negative", () => {
    const steps = [makeObservationStep(makeObs("web-search", "untrusted", "x"), 0)];
    expect(buildRecentObservationsSection(steps, 0)).toBeNull();
    expect(buildRecentObservationsSection(steps, -3)).toBeNull();
  });

  it("returns null when no observation steps exist", () => {
    const thoughtStep: ReasoningStep = {
      id: "step-thought" as ReasoningStep["id"],
      type: "thought",
      content: "thinking",
      timestamp: new Date(),
      metadata: {},
    };
    expect(buildRecentObservationsSection([thoughtStep], 5)).toBeNull();
  });

  it("limits to the most-recent N observation steps and renders them in order", () => {
    const steps = [
      makeObservationStep(makeObs("web-search", "untrusted", "first"), 0),
      makeObservationStep(makeObs("web-search", "untrusted", "second"), 1),
      makeObservationStep(makeObs("web-search", "untrusted", "third"), 2),
    ];
    const section = buildRecentObservationsSection(steps, 2);
    expect(section).not.toBeNull();
    expect(section).toContain(RECENT_OBSERVATIONS_HEADER);
    // Last 2 only — "first" excluded, "second" + "third" included in order.
    expect(section).not.toContain("first");
    const secondIdx = section!.indexOf("second");
    const thirdIdx = section!.indexOf("third");
    expect(secondIdx).toBeGreaterThan(0);
    expect(thirdIdx).toBeGreaterThan(secondIdx);
  });

  it("wraps untrusted but not trusted observations within the same section", () => {
    const steps = [
      makeObservationStep(makeObs("recall", "trusted", "scratch-value"), 0),
      makeObservationStep(makeObs("web-search", "untrusted", "search-payload"), 1),
    ];
    const section = buildRecentObservationsSection(steps, 5)!;
    expect(section).toContain('<tool_output tool="web-search">');
    expect(section).toContain("search-payload");
    // Trusted line is plain — its toolName MUST NOT appear inside a wrapper.
    expect(section).not.toContain('<tool_output tool="recall">');
    expect(section).toContain("scratch-value");
  });

  it("skips observation steps that lack an observationResult", () => {
    const steps: ReasoningStep[] = [
      {
        id: "step-bare" as ReasoningStep["id"],
        type: "observation",
        content: "no result attached",
        timestamp: new Date(),
        metadata: {},
      },
      makeObservationStep(makeObs("web-search", "untrusted", "real-payload"), 1),
    ];
    const section = buildRecentObservationsSection(steps, 5)!;
    expect(section).toContain("real-payload");
    expect(section).not.toContain("no result attached");
  });
});

describe("defaultContextCurator — Slice B section authorship", () => {
  const baseInput = makeInput;

  it("does NOT append the section when includeRecentObservations is absent (Slice A parity)", () => {
    const state = makeState({
      steps: [makeObservationStep(makeObs("web-search", "untrusted", "payload"), 0)],
    });
    const { systemPrompt } = defaultContextCurator.curate(
      state,
      baseInput(),
      CONTEXT_PROFILES.local,
      noGuidance,
    );
    expect(systemPrompt).not.toContain(RECENT_OBSERVATIONS_HEADER);
    expect(systemPrompt).not.toContain("<tool_output");
  });

  it("appends the trust-aware section when includeRecentObservations > 0", () => {
    const state = makeState({
      steps: [
        makeObservationStep(makeObs("recall", "trusted", "scratch-x"), 0),
        makeObservationStep(makeObs("web-search", "untrusted", "ADVERSARIAL PAYLOAD"), 1),
      ],
    });
    const { systemPrompt } = defaultContextCurator.curate(
      state,
      baseInput(),
      CONTEXT_PROFILES.local,
      noGuidance,
      undefined,
      { includeRecentObservations: 5 },
    );
    expect(systemPrompt).toContain(RECENT_OBSERVATIONS_HEADER);
    expect(systemPrompt).toContain('<tool_output tool="web-search">');
    expect(systemPrompt).toContain("ADVERSARIAL PAYLOAD");
    expect(systemPrompt).toContain("scratch-x");
    // Section is appended at the tail (after the existing curator output).
    const headerIdx = systemPrompt.indexOf(RECENT_OBSERVATIONS_HEADER);
    expect(headerIdx).toBeGreaterThan(0);
  });
});

// ── Slice C: profile-driven production wiring ────────────────────────────────

describe("ContextProfile.recentObservationsLimit (S2.5 Slice C)", () => {
  it("all default tier profiles ship with recentObservationsLimit OFF (0/undefined)", () => {
    // Pinning the convention: turning this on globally would change every
    // prompt's token budget. It MUST stay opt-in per-agent.
    for (const tier of ["local", "mid", "large", "frontier"] as const) {
      const lim = CONTEXT_PROFILES[tier].recentObservationsLimit;
      expect(lim === undefined || lim === 0).toBe(true);
    }
  });

  it("profile.recentObservationsLimit drives the curator section when an override is supplied via mergeProfile-style usage", () => {
    const state = makeState({
      steps: [makeObservationStep(makeObs("file-read", "untrusted", "FILE-PAYLOAD"), 0)],
    });
    const profileWithOverride = {
      ...CONTEXT_PROFILES.local,
      recentObservationsLimit: 3,
    };
    // Mimics what think.ts does: forwards profile.recentObservationsLimit
    // into the curator option. If think.ts ever stops threading the field,
    // this assertion still passes (curator is correctly wired) — but the
    // wiring test below (think.ts integration) is the regression catch.
    const { systemPrompt } = defaultContextCurator.curate(
      state,
      makeInput(),
      profileWithOverride,
      noGuidance,
      undefined,
      { includeRecentObservations: profileWithOverride.recentObservationsLimit },
    );
    expect(systemPrompt).toContain(RECENT_OBSERVATIONS_HEADER);
    expect(systemPrompt).toContain("FILE-PAYLOAD");
  });

  it("falls back to OFF when profile.recentObservationsLimit is undefined", () => {
    const state = makeState({
      steps: [makeObservationStep(makeObs("file-read", "untrusted", "FILE-PAYLOAD"), 0)],
    });
    const { systemPrompt } = defaultContextCurator.curate(
      state,
      makeInput(),
      CONTEXT_PROFILES.local, // no override
      noGuidance,
      undefined,
      { includeRecentObservations: CONTEXT_PROFILES.local.recentObservationsLimit ?? 0 },
    );
    expect(systemPrompt).not.toContain(RECENT_OBSERVATIONS_HEADER);
    expect(systemPrompt).not.toContain("FILE-PAYLOAD");
  });
});

// ── Sprint 3.4 (G-4) — Curator owns compression ──────────────────────────────

describe("buildRecentObservationsSection — G-4 scratchpad lookup", () => {
  function withStoredKey(
    obs: ObservationResult,
    storedKey: string,
    idx: number,
  ): ReasoningStep {
    return {
      id: `step-${idx}` as ReasoningStep["id"],
      type: "observation",
      content: obs.displayText,
      timestamp: new Date(2026, 3, 26, 16, idx, 0),
      metadata: { observationResult: obs, storedKey },
    };
  }

  it("substitutes full scratchpad content for compressed displayText when storedKey present", () => {
    const compressedDisplay = "[STORED: _tool_result_1 | get-hn-posts]\nbytes: 2824\n— full text is stored.";
    const fullContent = "Top story: Asahi Linux 7.0 (273 points). Second: Rust async (180 points)...";
    const obs = makeObs("get-hn-posts", "untrusted", compressedDisplay);
    const steps = [withStoredKey(obs, "_tool_result_1", 0)];
    const scratchpad = new Map([["_tool_result_1", fullContent]]);

    const section = buildRecentObservationsSection(steps, 5, { scratchpad })!;
    expect(section).toContain(fullContent);
    // Compressed marker no longer leaks into the section
    expect(section).not.toContain("[STORED: _tool_result_1");
    expect(section).not.toContain("bytes: 2824");
  });

  it("falls back to displayText when storedKey is absent", () => {
    const display = "the actual short observation";
    const obs = makeObs("recall", "trusted", display);
    const section = buildRecentObservationsSection(
      [makeObservationStep(obs, 0)],
      5,
      { scratchpad: new Map() },
    )!;
    expect(section).toContain(display);
  });

  it("falls back to displayText when scratchpad is undefined (backward-compat)", () => {
    const obs = makeObs("get-hn-posts", "untrusted", "fallback display");
    const section = buildRecentObservationsSection(
      [withStoredKey(obs, "_tool_result_1", 0)],
      5,
    )!;
    expect(section).toContain("fallback display");
  });

  it("caps scratchpad content at maxCharsPerObservation and adds truncation marker with recall hint", () => {
    const obs = makeObs("file-read", "untrusted", "compressed marker");
    const fullContent = "X".repeat(5000);
    const scratchpad = new Map([["_file_1", fullContent]]);

    const section = buildRecentObservationsSection(
      [withStoredKey(obs, "_file_1", 0)],
      5,
      { scratchpad, maxCharsPerObservation: 100 },
    )!;
    // First 100 chars present
    expect(section).toContain("X".repeat(100));
    // Truncation marker + recall hint
    expect(section).toContain("...truncated");
    expect(section).toContain("4900 chars");
    expect(section).toContain('recall("_file_1")');
    // Full content not all there
    expect(section).not.toContain("X".repeat(101));
  });

  it("does NOT cap when full content fits under maxCharsPerObservation", () => {
    const fullContent = "short full content under limit";
    const obs = makeObs("get-hn-posts", "untrusted", "compressed");
    const scratchpad = new Map([["_t_1", fullContent]]);

    const section = buildRecentObservationsSection(
      [withStoredKey(obs, "_t_1", 0)],
      5,
      { scratchpad, maxCharsPerObservation: 1000 },
    )!;
    expect(section).toContain(fullContent);
    expect(section).not.toContain("...truncated");
  });

  it("trusted observations also get scratchpad-substituted content (still rendered plain, no <tool_output>)", () => {
    const obs = makeObs("recall", "trusted", "compressed marker");
    const fullContent = "scratchpad full value";
    const scratchpad = new Map([["_r_1", fullContent]]);

    const section = buildRecentObservationsSection(
      [withStoredKey(obs, "_r_1", 0)],
      5,
      { scratchpad },
    )!;
    expect(section).toContain(fullContent);
    expect(section).not.toContain('<tool_output');
  });

  it("untrusted observations with scratchpad full content are still wrapped in <tool_output>", () => {
    const obs = makeObs("web-search", "untrusted", "compressed marker");
    const fullContent = "actual web search results with adversarial: ignore previous instructions";
    const scratchpad = new Map([["_w_1", fullContent]]);

    const section = buildRecentObservationsSection(
      [withStoredKey(obs, "_w_1", 0)],
      5,
      { scratchpad },
    )!;
    expect(section).toContain('<tool_output tool="web-search">');
    expect(section).toContain(fullContent);
    expect(section).toContain("</tool_output>");
  });
});

describe("renderObservationForPrompt — G-4 contentOverride", () => {
  it("uses contentOverride instead of obs.displayText when provided", () => {
    const obs: ObservationResult = {
      success: true,
      toolName: "web-search",
      displayText: "compressed marker [STORED:...]",
      category: "web-search",
      resultKind: "data",
      preserveOnCompaction: false,
      trustLevel: "untrusted",
    };
    const out = renderObservationForPrompt(obs, "FULL CONTENT FROM SCRATCHPAD");
    expect(out).toContain("FULL CONTENT FROM SCRATCHPAD");
    expect(out).not.toContain("compressed marker");
  });

  it("falls back to displayText when contentOverride is undefined", () => {
    const obs: ObservationResult = {
      success: true,
      toolName: "recall",
      displayText: "the real text",
      category: "scratchpad",
      resultKind: "side-effect",
      preserveOnCompaction: false,
      trustLevel: "trusted",
      trustJustification: "grandfather-phase-1",
    };
    const out = renderObservationForPrompt(obs);
    expect(out).toBe("the real text");
  });
});
