/**
 * Session resume: `agent.resumeSession` and setHistory injection.
 *
 * - Resume source is the Trace file with the latest index; config carries over from session_meta
 *   (Workspace / Model cannot be swapped).
 * - Pairing-fallback placeholders, once constructed, are written into the original trace file;
 *   session_meta is never written twice.
 * - Errors when the session doesn't exist / the workspace is missing / the model is no longer in the project config.
 * - `groupHistoryToUniMessages` groups by adjacent same role; `GenerativeModel.setHistory` injects into AgentHub.
 */
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { createAgent } from "../src/index.js";
import {
  abortEvent,
  assistantText,
  requestBegin,
  requestEnd,
  sessionMeta,
  tokenUsage,
  toolCall,
  userText,
} from "../src/omnimessage/index.js";
import type { OmniMessage, TokenCounts } from "../src/omnimessage/index.js";
import { GenerativeModel, groupHistoryToUniMessages } from "../src/llm/index.js";
import { readTrace } from "../src/trace/index.js";
import { tracesDir } from "../src/state/paths.js";
import { stubProviderKeys } from "./provider-keys.js";

// The default project config ships with this model ((provider, model_id) pair reference; model_id is the upstream id).
const MODEL = { provider: "anthropic", model_id: "claude-sonnet-4-6" };

let tmpRoot: string;
let workspace: string;
let prevHome: string | undefined;
let restoreKeys: () => void;

beforeEach(async () => {
  prevHome = process.env.PENGUIN_HOME;
  tmpRoot = await fs.mkdtemp(path.join(os.tmpdir(), "penguin-resume-"));
  workspace = await fs.mkdtemp(path.join(os.tmpdir(), "penguin-resume-ws-"));
  process.env.PENGUIN_HOME = tmpRoot;
  restoreKeys = stubProviderKeys();
});

afterEach(async () => {
  if (prevHome === undefined) delete process.env.PENGUIN_HOME;
  else process.env.PENGUIN_HOME = prevHome;
  restoreKeys();
  await fs.rm(tmpRoot, { recursive: true, force: true });
  await fs.rm(workspace, { recursive: true, force: true });
});

const usage = (total: number): TokenCounts => ({
  cache_read: 0,
  cache_write: 0,
  output: 1,
  total,
});

/** Manually constructs a session's trace file (simulating a record left behind by a previous process). */
async function writeTraceFile(
  root: string,
  sessionId: string,
  messages: OmniMessage[],
  opts?: { dateDir?: string; index?: string },
): Promise<string> {
  const dir = path.join(
    tracesDir(root, "default_project", "default_agent"),
    opts?.dateDir ?? "2026-07-06",
  );
  await fs.mkdir(dir, { recursive: true });
  const file = path.join(dir, `${sessionId}_${opts?.index ?? "001"}.jsonl`);
  await fs.writeFile(file, messages.map((m) => JSON.stringify(m)).join("\n") + "\n", "utf8");
  return file;
}

function metaFor(
  sessionId: string,
  workspaceDir: string,
  model = MODEL,
  source?: "subagent" | "schedule",
): OmniMessage {
  return sessionMeta({
    session_id: sessionId,
    provider: model.provider,
    model_id: model.model_id,
    model_context_window: 1000000,
    system_prompt: "ORIGINAL SYSTEM PROMPT",
    tools: [],
    agent_state: "/agent/state",
    workspace: workspaceDir,
    ...(source !== undefined ? { source } : {}),
  });
}

describe("agent.resumeSession", () => {
  const SID = "session-2026-07-06-10-00-00-abcdef01";

  it("resumes from the latest trace file and exposes render history", async () => {
    const agent = await createAgent({});
    await writeTraceFile(tmpRoot, SID, [
      metaFor(SID, workspace),
      userText("hello"),
      requestBegin(),
      assistantText("hi there"),
      requestEnd("completed"),
      tokenUsage(usage(42), usage(42)),
    ]);

    const session = await agent.resumeSession({ sessionId: SID });
    expect(session.sessionId).toBe(SID);
    expect(session.provider).toBe(MODEL.provider);
    expect(session.modelId).toBe(MODEL.model_id);
    expect(session.workspaceDir).toBe(workspace);
    const texts = (session.resumedHistory ?? []).map(
      (m) => (m.payload as { text?: string }).text ?? "",
    );
    expect(texts).toEqual(["hello", "hi there"]);
  });

  it("preserves the stored session source across resume (and its absence for user sessions)", async () => {
    const agent = await createAgent({});
    await writeTraceFile(tmpRoot, SID, [
      metaFor(SID, workspace, MODEL, "subagent"),
      userText("child task"),
      requestBegin(),
      assistantText("done"),
      requestEnd("completed"),
    ]);
    const session = await agent.resumeSession({ sessionId: SID });
    // The rebuilt session_meta carries the origin over (a rotated Trace file must re-record it).
    expect((session.metaMessage.payload as { source?: string }).source).toBe("subagent");
    session.dispose();

    // A user-created session's meta has no source key, and resume must not invent one.
    const SID2 = "session-2026-07-06-11-00-00-abcdef02";
    await writeTraceFile(tmpRoot, SID2, [metaFor(SID2, workspace), userText("hi")]);
    const plain = await agent.resumeSession({ sessionId: SID2 });
    expect("source" in (plain.metaMessage.payload as unknown as Record<string, unknown>)).toBe(
      false,
    );
    plain.dispose();

    // On-disk values are untrusted: a junk source written by a third party is dropped on
    // resume (only the exact known origins pass), not cast through into the rebuilt meta.
    const SID3 = "session-2026-07-06-12-00-00-abcdef03";
    await writeTraceFile(tmpRoot, SID3, [
      metaFor(SID3, workspace, MODEL, "weird-origin" as unknown as "subagent"),
      userText("hi"),
    ]);
    const junk = await agent.resumeSession({ sessionId: SID3 });
    expect("source" in (junk.metaMessage.payload as unknown as Record<string, unknown>)).toBe(
      false,
    );
    junk.dispose();
  });

  it("keeps abort events in resumed render history", async () => {
    const agent = await createAgent({});
    await writeTraceFile(tmpRoot, SID, [
      metaFor(SID, workspace),
      userText("long task"),
      requestBegin(),
      assistantText("partial answer", "aborted"),
      requestEnd("aborted"),
      abortEvent("aborted by user"),
    ]);

    const session = await agent.resumeSession({ sessionId: SID });
    expect(
      (session.resumedHistory ?? []).map((m) => (m.payload as { type?: string }).type),
    ).toEqual(["text", "text", "abort"]);
  });

  it("ignores a legacy trace's recorded thinking_level; the Agent config always wins", async () => {
    // session_meta no longer carries a thinking level (it became a per-turn run parameter);
    // a `thinking_level` still present in an OLD trace's meta JSON is deliberately ignored —
    // resume always reads this Agent's current config, so a resumed legacy subagent session
    // falls back to the config level instead of keeping the level it inherited at spawn
    // time. The seeded Agent config here pins "medium".
    const agent = await createAgent({});
    expect(agent.state.systemConfig.model?.thinking_level).toBe("medium");
    const levelOf = (session: unknown): unknown =>
      (session as { engine: { deps: { llm: { defaultThinkingLevel?: unknown } } } }).engine.deps.llm
        .defaultThinkingLevel;

    const recorded = metaFor(SID, workspace);
    // A legacy trace: inject the retired field loosely into the on-disk meta JSON.
    (recorded.payload as unknown as Record<string, unknown>).thinking_level = "xhigh";
    await writeTraceFile(tmpRoot, SID, [recorded, userText("hello")]);
    const ignored = await agent.resumeSession({ sessionId: SID });
    expect(levelOf(ignored)).toBe("medium");
    // The rebuilt meta holds invariants only: the legacy field is never re-recorded either.
    expect(
      "thinking_level" in (ignored.metaMessage.payload as unknown as Record<string, unknown>),
    ).toBe(false);

    // A current trace (no field) — and the legacy literal "default" — read the Agent config too.
    const SID2 = "session-2026-07-06-11-00-00-abcdef02";
    await writeTraceFile(tmpRoot, SID2, [metaFor(SID2, workspace), userText("hi")]);
    const fallback = await agent.resumeSession({ sessionId: SID2 });
    expect(levelOf(fallback)).toBe("medium");

    const SID3 = "session-2026-07-06-12-00-00-abcdef03";
    const legacyDefault = metaFor(SID3, workspace);
    (legacyDefault.payload as unknown as Record<string, unknown>).thinking_level = "default";
    await writeTraceFile(tmpRoot, SID3, [legacyDefault, userText("hi")]);
    const viaDefault = await agent.resumeSession({ sessionId: SID3 });
    expect(levelOf(viaDefault)).toBe("medium");
  });

  it("does not write pairing placeholders to the trace file (resume is side-effect free)", async () => {
    const agent = await createAgent({});
    const file = await writeTraceFile(tmpRoot, SID, [
      metaFor(SID, workspace),
      userText("go"),
      requestBegin(),
      toolCall({ name: "exec_command", arguments: "{}", toolCallId: "tc1" }),
      requestEnd("completed"),
      tokenUsage(usage(10), usage(10)),
      // tc1's output was lost along with the process: the pairing placeholder is synthesized in memory and sent out with the next run, never persisted.
    ]);
    const before = await readTrace(file);

    await agent.resumeSession({ sessionId: SID });
    await agent.resumeSession({ sessionId: SID }); // resuming again has no side effects either
    const after = await readTrace(file);
    expect(after).toHaveLength(before.length);
    expect(
      after.filter((m) =>
        ((m.payload as { output?: string }).output ?? "").includes("interrupted"),
      ),
    ).toHaveLength(0);
    // session_meta is never written twice.
    expect(after.filter((m) => m.type === "session_meta")).toHaveLength(1);
  });

  it("picks the latest index when the context was compacted into multiple files", async () => {
    const agent = await createAgent({});
    await writeTraceFile(tmpRoot, SID, [metaFor(SID, workspace), userText("old context")], {
      index: "001",
    });
    await writeTraceFile(
      tmpRoot,
      SID,
      [
        metaFor(SID, workspace),
        userText("[context_summary]gist[/context_summary]"),
        requestBegin(),
        assistantText("resumed context"),
        requestEnd("completed"),
        tokenUsage(usage(5), usage(5)),
      ],
      { index: "002" },
    );

    const session = await agent.resumeSession({ sessionId: SID });
    const texts = (session.resumedHistory ?? []).map(
      (m) => (m.payload as { text?: string }).text ?? "",
    );
    expect(texts).toEqual(["[context_summary]gist[/context_summary]", "resumed context"]);
  });

  it("errors when the session does not exist", async () => {
    const agent = await createAgent({});
    await expect(agent.resumeSession({ sessionId: "session-none" })).rejects.toThrow(
      /Session does not exist/,
    );
  });

  it("errors when the recorded workspace no longer exists (PRN-004: no auto-create)", async () => {
    const agent = await createAgent({});
    const gone = path.join(workspace, "gone");
    await writeTraceFile(tmpRoot, SID, [metaFor(SID, gone), userText("x")]);
    await expect(agent.resumeSession({ sessionId: SID })).rejects.toThrow(
      /Workspace no longer exists/,
    );
  });

  it("errors when the recorded model is no longer in the project config", async () => {
    const agent = await createAgent({});
    await writeTraceFile(tmpRoot, SID, [
      metaFor(SID, workspace, { provider: "custom", model_id: "vanished-model" }),
      userText("x"),
    ]);
    await expect(agent.resumeSession({ sessionId: SID })).rejects.toThrow(
      /is not in the Project config/,
    );
  });

  it("errors clearly when session_meta lacks provider (old-format trace, no migration)", async () => {
    // An old-format trace's session_meta only has model_id (from the composite-id era): no backward compat, just a clear error.
    const agent = await createAgent({});
    const legacy = metaFor(SID, workspace);
    delete (legacy.payload as { provider?: string }).provider;
    await writeTraceFile(tmpRoot, SID, [legacy, userText("x")]);
    await expect(agent.resumeSession({ sessionId: SID })).rejects.toThrow(/legacy data/);
  });

  it("latestSessionId returns the newest session by embedded timestamp", async () => {
    const agent = await createAgent({});
    expect(await agent.latestSessionId()).toBeNull();
    const older = "session-2026-07-05-09-00-00-aaaaaaaa";
    const newer = "session-2026-07-06-11-00-00-bbbbbbbb";
    await writeTraceFile(tmpRoot, older, [metaFor(older, workspace), userText("older")], {
      dateDir: "2026-07-05",
    });
    await writeTraceFile(tmpRoot, newer, [metaFor(newer, workspace), userText("newer")], {
      dateDir: "2026-07-06",
    });
    expect(await agent.latestSessionId()).toBe(newer);
  });

  it("latestSessionId ignores empty traces that only contain session_meta", async () => {
    const agent = await createAgent({});
    const older = "session-2026-07-05-09-00-00-aaaaaaaa";
    const emptyNewer = "session-2026-07-06-11-00-00-bbbbbbbb";
    await writeTraceFile(tmpRoot, older, [metaFor(older, workspace), userText("older")], {
      dateDir: "2026-07-05",
    });
    await writeTraceFile(tmpRoot, emptyNewer, [metaFor(emptyNewer, workspace)], {
      dateDir: "2026-07-06",
    });
    expect(await agent.latestSessionId()).toBe(older);
  });

  it("manual compact on a new empty session does not create a resumable trace", async () => {
    const agent = await createAgent({});
    const session = await agent.createSession({ workspaceDir: workspace });
    const messages = [];
    for await (const msg of session.compact()) messages.push(msg);
    expect(messages).toHaveLength(0);
    expect(await agent.latestSessionId()).toBeNull();
  });
});

describe("setHistory injection", () => {
  it("groupHistoryToUniMessages groups adjacent same-role messages into UniMessages", () => {
    const uni = groupHistoryToUniMessages([
      userText("hello"),
      assistantText("hi"),
      toolCall({ name: "exec_command", arguments: '{"cmd":"ls"}', toolCallId: "tc1" }),
      {
        ...userText("ignored-shape"),
        payload: {
          type: "tool_call_output",
          role: "user",
          output: "out",
          tool_call_id: "tc1",
          stop_reason: "completed",
        },
      } as OmniMessage,
      userText("next"),
      assistantText("done"),
    ]);
    expect(uni.map((m) => m.role)).toEqual(["user", "assistant", "user", "assistant"]);
    expect(uni[1]!.content_items.map((c) => c.type)).toEqual(["text", "tool_call"]);
    expect(uni[2]!.content_items.map((c) => c.type)).toEqual(["tool_result", "text"]);
  });

  it("GenerativeModel.setHistory seeds the AgentHub client history", () => {
    // GenerativeModel takes the request id sent to AgentHub (the upstream id), not the storage id.
    const model = new GenerativeModel({ modelId: "claude-sonnet-4-6", tools: [] });
    model.setHistory([userText("hello"), assistantText("hi")]);
    const client = (model as unknown as { client: { getHistory(): unknown[] } }).client;
    expect(client.getHistory()).toHaveLength(2);
  });
});
