import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { serializeAgentFile } from "../src/agent-file-toggle.js";
import { BUILTIN_TOOL_NAMES } from "../src/agent-types.js";
import { loadCustomAgents } from "../src/custom-agents.js";
import type { AgentConfig } from "../src/types.js";

describe("loadCustomAgents", () => {
  let tmpDir: string;
  let originalHome: string | undefined;
  let originalAgentDir: string | undefined;

  beforeEach(() => {
    tmpDir = mkdtempSync(join(tmpdir(), "pi-test-"));
    originalHome = process.env.HOME;
    originalAgentDir = process.env.PI_CODING_AGENT_DIR;
    process.env.HOME = tmpDir;
    delete process.env.PI_CODING_AGENT_DIR;
  });

  afterEach(() => {
    if (originalHome == null) delete process.env.HOME;
    else process.env.HOME = originalHome;
    if (originalAgentDir == null) delete process.env.PI_CODING_AGENT_DIR;
    else process.env.PI_CODING_AGENT_DIR = originalAgentDir;
    rmSync(tmpDir, { recursive: true, force: true });
  });

  function writeAgentIn(projectDir: ".agents" | ".pi", name: string, content: string) {
    const dir = join(tmpDir, projectDir, "agents");
    mkdirSync(dir, { recursive: true });
    writeFileSync(join(dir, `${name}.md`), content);
  }

  function writeAgent(name: string, content: string) {
    writeAgentIn(".pi", name, content);
  }

  function writeWorkspaceAgent(name: string, content: string) {
    writeAgentIn(".agents", name, content);
  }

  it("returns empty map when custom agent dirs do not exist", () => {
    const result = loadCustomAgents(tmpDir);
    expect(result.size).toBe(0);
  });

  it("loads a workspace project agent from .agents/agents", () => {
    writeWorkspaceAgent("reviewer", `---
description: Workspace Reviewer
---

Workspace prompt.`);

    const result = loadCustomAgents(tmpDir);
    expect(result.size).toBe(1);
    expect(result.get("reviewer")?.description).toBe("Workspace Reviewer");
    expect(result.get("reviewer")?.systemPrompt).toBe("Workspace prompt.");
    expect(result.get("reviewer")?.source).toBe("project");
  });

  it(".pi/agents overrides .agents/agents on a name clash", () => {
    writeWorkspaceAgent("dupe", `---
description: Workspace Project
---

Workspace prompt.`);
    writeAgent("dupe", `---
description: Pi Project
---

Pi prompt.`);

    const result = loadCustomAgents(tmpDir);
    expect(result.size).toBe(1);
    expect(result.get("dupe")?.description).toBe("Pi Project");
    expect(result.get("dupe")?.systemPrompt).toBe("Pi prompt.");
  });

  it("workspace project agents override global agents", () => {
    const globalAgentDir = join(tmpDir, "global-agent-dir");
    process.env.PI_CODING_AGENT_DIR = globalAgentDir;
    const globalAgents = join(globalAgentDir, "agents");
    mkdirSync(globalAgents, { recursive: true });
    writeFileSync(join(globalAgents, "dupe.md"), `---
description: Global
---

Global prompt.`);
    writeWorkspaceAgent("dupe", `---
description: Workspace Project
---

Workspace prompt.`);

    const result = loadCustomAgents(tmpDir);
    expect(result.size).toBe(1);
    expect(result.get("dupe")?.description).toBe("Workspace Project");
    expect(result.get("dupe")?.systemPrompt).toBe("Workspace prompt.");
  });

  it("loads a basic agent with all frontmatter fields", () => {
    writeAgent("auditor", `---
description: Security Auditor
tools: read, grep, find
model: anthropic/claude-opus-4-6
thinking: high
max_turns: 30
persist_session: true
output_transcript: false
session_dir: .seams/pi-sessions/seam-plan-reviewer
allowed_subagents: scout, reviewer
prompt_mode: replace
inherit_context: true
run_in_background: true
isolated: true
---

You are a security auditor.`);

    const result = loadCustomAgents(tmpDir);
    expect(result.size).toBe(1);

    const agent = result.get("auditor")!;
    expect(agent.name).toBe("auditor");
    expect(agent.description).toBe("Security Auditor");
    expect(agent.builtinToolNames).toEqual(["read", "grep", "find"]);
    expect(agent.model).toBe("anthropic/claude-opus-4-6");
    expect(agent.thinking).toBe("high");
    expect(agent.maxTurns).toBe(30);
    expect(agent.persistSession).toBe(true);
    expect(agent.outputTranscript).toBe(false);
    expect(agent.sessionDir).toBe(".seams/pi-sessions/seam-plan-reviewer");
    expect(agent.allowedSubagents).toEqual(["scout", "reviewer"]);
    expect(agent.promptMode).toBe("replace");
    expect(agent.inheritContext).toBe(true);
    expect(agent.runInBackground).toBe(true);
    expect(agent.isolated).toBe(true);
    expect(agent.systemPrompt).toBe("You are a security auditor.");
  });

  it("uses sensible defaults when frontmatter is empty", () => {
    writeAgent("minimal", `---
---

Just a prompt.`);

    const result = loadCustomAgents(tmpDir);
    const agent = result.get("minimal")!;

    expect(agent.name).toBe("minimal");
    expect(agent.displayName).toBeUndefined();
    expect(agent.color).toBeUndefined();
    expect(agent.description).toBe("minimal"); // defaults to filename
    expect(agent.builtinToolNames).toEqual(BUILTIN_TOOL_NAMES); // all tools
    expect(agent.extensions).toBe(true); // inherit all
    expect(agent.skills).toBe(true); // inherit all
    expect(agent.model).toBeUndefined();
    expect(agent.thinking).toBeUndefined();
    expect(agent.maxTurns).toBeUndefined();
    expect(agent.persistSession).toBeUndefined();
    expect(agent.outputTranscript).toBeUndefined();
    expect(agent.sessionDir).toBeUndefined();
    expect(agent.allowedSubagents).toBeUndefined();
    expect(agent.promptMode).toBe("replace");
    expect(agent.inheritContext).toBeUndefined();
    expect(agent.runInBackground).toBeUndefined();
    expect(agent.isolated).toBeUndefined();
    expect(agent.systemPrompt).toBe("Just a prompt.");
  });

  it("uses sensible defaults when no frontmatter at all", () => {
    writeAgent("bare", "Just a system prompt, no frontmatter.");

    const result = loadCustomAgents(tmpDir);
    const agent = result.get("bare")!;

    expect(agent.name).toBe("bare");
    expect(agent.description).toBe("bare");
    expect(agent.builtinToolNames).toEqual(BUILTIN_TOOL_NAMES);
    expect(agent.systemPrompt).toBe("Just a system prompt, no frontmatter.");
  });

  it("parses allowed_subagents: off by default, `all` wildcard, csv restriction", () => {
    writeAgent("omitted", `---
---
Off.`);
    writeAgent("unrestricted", `---
allowed_subagents: all
---
Unrestricted.`);
    writeAgent("wildcard", `---
allowed_subagents: "*"
---
Unrestricted.`);
    writeAgent("mixed-case", `---
allowed_subagents: scout, ALL
---
Unrestricted.`);
    writeAgent("none", `---
allowed_subagents: none
---
Off.`);
    writeAgent("blank", `---
allowed_subagents:
---
Off.`);
    writeAgent("restricted", `---
allowed_subagents: scout, reviewer
---
Restricted.`);

    const result = loadCustomAgents(tmpDir);
    expect(result.get("omitted")!.allowedSubagents).toBeUndefined();
    expect(result.get("unrestricted")!.allowedSubagents).toBe("all");
    expect(result.get("wildcard")!.allowedSubagents).toBe("all");
    expect(result.get("mixed-case")!.allowedSubagents).toBe("all");
    expect(result.get("none")!.allowedSubagents).toBeUndefined();
    expect(result.get("blank")!.allowedSubagents).toBeUndefined();
    expect(result.get("restricted")!.allowedSubagents).toEqual(["scout", "reviewer"]);
  });

  it("accepts booleans like extensions:/skills: do, instead of a type named \"true\"", () => {
    writeAgent("bool-on", `---
allowed_subagents: true
---
On.`);
    writeAgent("bool-off", `---
allowed_subagents: false
---
Off.`);

    const result = loadCustomAgents(tmpDir);
    expect(result.get("bool-on")!.allowedSubagents).toBe("all");
    expect(result.get("bool-off")!.allowedSubagents).toBeUndefined();
  });

  it("handles tools: none → empty array", () => {
    writeAgent("notool", `---
tools: none
---

No tools.`);

    const result = loadCustomAgents(tmpDir);
    expect(result.get("notool")!.builtinToolNames).toEqual([]);
  });

  it("handles extensions: false → no extensions", () => {
    writeAgent("noext", `---
extensions: false
skills: false
---

No extensions.`);

    const result = loadCustomAgents(tmpDir);
    const agent = result.get("noext")!;
    expect(agent.extensions).toBe(false);
    expect(agent.skills).toBe(false);
  });

  it("handles extension allowlist", () => {
    writeAgent("partial", `---
extensions: web-search, mcp-server
skills: planning, review
---

Partial access.`);

    const result = loadCustomAgents(tmpDir);
    const agent = result.get("partial")!;
    expect(agent.extensions).toEqual(["web-search", "mcp-server"]);
    expect(agent.skills).toEqual(["planning", "review"]);
  });

  it("parses exclude_extensions CSV", () => {
    writeAgent("no-notify", `---
extensions: true
exclude_extensions: pi-notify, telemetry
---

No notifications.`);

    const result = loadCustomAgents(tmpDir);
    const agent = result.get("no-notify")!;
    expect(agent.extensions).toBe(true);
    expect(agent.excludeExtensions).toEqual(["pi-notify", "telemetry"]);
  });

  it("parses exclude_extensions YAML list", () => {
    writeAgent("no-notify-yaml", `---
exclude_extensions:
  - pi-notify
---

No notifications.`);

    const result = loadCustomAgents(tmpDir);
    expect(result.get("no-notify-yaml")!.excludeExtensions).toEqual(["pi-notify"]);
  });

  it("exclude_extensions omitted or none → undefined", () => {
    writeAgent("plain", `---
description: plain
---

Plain.`);
    writeAgent("explicit-none", `---
exclude_extensions: none
---

None.`);

    const result = loadCustomAgents(tmpDir);
    expect(result.get("plain")!.excludeExtensions).toBeUndefined();
    expect(result.get("explicit-none")!.excludeExtensions).toBeUndefined();
  });

  it("passes through unknown tool names (not filtered)", () => {
    writeAgent("custom-tools", `---
tools: read, my_custom_tool, grep
---

Custom tools.`);

    const result = loadCustomAgents(tmpDir);
    // Unknown tool names are passed through — filtering happens at tool creation time
    expect(result.get("custom-tools")!.builtinToolNames).toEqual(["read", "my_custom_tool", "grep"]);
  });

  it("partitions tools: ext: entries out of builtinToolNames into extSelectors", () => {
    writeAgent("ext-agent", `---
tools: read, ext:foo, ext:bar/x
---

Extension selectors.`);

    const agent = loadCustomAgents(tmpDir).get("ext-agent")!;
    expect(agent.builtinToolNames).toEqual(["read"]);
    expect(agent.extSelectors).toEqual(["ext:foo", "ext:bar/x"]);
  });

  it("tools: with only ext: entries yields zero built-ins", () => {
    writeAgent("ext-only", `---
tools: ext:foo/bar
---

Ext only.`);

    const agent = loadCustomAgents(tmpDir).get("ext-only")!;
    expect(agent.builtinToolNames).toEqual([]);
    expect(agent.extSelectors).toEqual(["ext:foo/bar"]);
  });

  it("tools: '*' expands to all built-ins and composes with ext: selectors", () => {
    writeAgent("wild", `---
tools: "*, ext:foo"
---

Wildcard plus ext.`);

    const agent = loadCustomAgents(tmpDir).get("wild")!;
    expect(agent.builtinToolNames).toEqual(BUILTIN_TOOL_NAMES);
    expect(agent.extSelectors).toEqual(["ext:foo"]);
  });

  it("tools: 'all' is a case-insensitive alias for '*' (closes #75)", () => {
    // `tools: all` previously parsed "all" as a single tool name → allowlist
    // containing the non-existent tool "all" → silent zero-tool agent.
    for (const [name, value] of [["all-lower", "all"], ["all-upper", "ALL"], ["all-mixed", "All"]]) {
      writeAgent(name, `---\ntools: ${value}\n---\n\nAlias.`);
      const agent = loadCustomAgents(tmpDir).get(name)!;
      expect(agent.builtinToolNames).toEqual(BUILTIN_TOOL_NAMES);
      expect(agent.extSelectors).toBeUndefined();
    }
  });

  it("tools: 'all' composes with ext: selectors like '*'", () => {
    writeAgent("all-plus-ext", `---
tools: "all, ext:foo"
---

All plus ext.`);

    const agent = loadCustomAgents(tmpDir).get("all-plus-ext")!;
    expect(agent.builtinToolNames).toEqual(BUILTIN_TOOL_NAMES);
    expect(agent.extSelectors).toEqual(["ext:foo"]);
  });

  it("leaves extSelectors undefined when tools: has no ext: entries", () => {
    writeAgent("plain", `---
tools: read, bash
---

Plain tools.`);

    const agent = loadCustomAgents(tmpDir).get("plain")!;
    expect(agent.builtinToolNames).toEqual(["read", "bash"]);
    expect(agent.extSelectors).toBeUndefined();
  });

  it("passes through thinking level as-is (no validation)", () => {
    writeAgent("anythink", `---
thinking: turbo
---

Any thinking.`);

    const result = loadCustomAgents(tmpDir);
    // Pi validates at session creation — we just pass through
    expect(result.get("anythink")!.thinking).toBe("turbo");
  });

  it("loads thinking: max (pi 0.80's top level) unchanged (#147)", () => {
    writeAgent("deepthink", `---
thinking: max
---

Think hard.`);

    const result = loadCustomAgents(tmpDir);
    expect(result.get("deepthink")!.thinking).toBe("max");
  });

  it("accepts max_turns: 0 as unlimited", () => {
    writeAgent("unlimited", `---
max_turns: 0
---

Unlimited turns.`);

    const result = loadCustomAgents(tmpDir);
    expect(result.get("unlimited")!.maxTurns).toBe(0);
  });

  it("rejects negative max_turns", () => {
    writeAgent("negturns", `---
max_turns: -5
---

Negative turns.`);

    const result = loadCustomAgents(tmpDir);
    expect(result.get("negturns")!.maxTurns).toBeUndefined();
  });

  it("handles prompt_mode: append", () => {
    writeAgent("appender", `---
prompt_mode: append
---

Extra instructions.`);

    const result = loadCustomAgents(tmpDir);
    expect(result.get("appender")!.promptMode).toBe("append");
  });

  it("defaults unknown prompt_mode to replace", () => {
    writeAgent("badmode", `---
prompt_mode: merge
---

Unknown mode.`);

    const result = loadCustomAgents(tmpDir);
    expect(result.get("badmode")!.promptMode).toBe("replace");
  });

  it("loads multiple agents", () => {
    writeAgent("agent1", `---
description: First
---

First agent.`);
    writeAgent("agent2", `---
description: Second
---

Second agent.`);

    const result = loadCustomAgents(tmpDir);
    expect(result.size).toBe(2);
    expect(result.has("agent1")).toBe(true);
    expect(result.has("agent2")).toBe(true);
  });

  it("skips non-.md files", () => {
    const dir = join(tmpDir, ".pi", "agents");
    mkdirSync(dir, { recursive: true });
    writeFileSync(join(dir, "notes.txt"), "not an agent");
    writeFileSync(join(dir, "real.md"), `---
description: Real Agent
---

Real.`);

    const result = loadCustomAgents(tmpDir);
    expect(result.size).toBe(1);
    expect(result.has("real")).toBe(true);
  });

  it("allows agents with names matching defaults (overrides them)", () => {
    writeAgent("Explore", `---
description: Custom Explore
---

Custom explore agent.`);
    writeAgent("custom", `---
description: Custom Agent
---

Should be loaded.`);

    const result = loadCustomAgents(tmpDir);
    expect(result.has("Explore")).toBe(true);
    expect(result.get("Explore")!.description).toBe("Custom Explore");
    expect(result.has("custom")).toBe(true);
  });

  it("handles empty body with frontmatter", () => {
    writeAgent("nobody", `---
description: No body
tools: read
---
`);

    const result = loadCustomAgents(tmpDir);
    expect(result.get("nobody")!.systemPrompt).toBe("");
  });

  it("supports inherit_extensions as alternative to extensions", () => {
    writeAgent("altkey", `---
inherit_extensions: false
inherit_skills: false
---

Alt keys.`);

    const result = loadCustomAgents(tmpDir);
    const agent = result.get("altkey")!;
    expect(agent.extensions).toBe(false);
    expect(agent.skills).toBe(false);
  });

  it("extensions: none → false", () => {
    writeAgent("extnone", `---
extensions: none
skills: none
---

None.`);

    const result = loadCustomAgents(tmpDir);
    const agent = result.get("extnone")!;
    expect(agent.extensions).toBe(false);
    expect(agent.skills).toBe(false);
  });

  it("extensions: true → true (inherit all)", () => {
    writeAgent("exttrue", `---
extensions: true
skills: true
---

All.`);

    const result = loadCustomAgents(tmpDir);
    const agent = result.get("exttrue")!;
    expect(agent.extensions).toBe(true);
    expect(agent.skills).toBe(true);
  });

  it("handles enabled: false frontmatter", () => {
    writeAgent("disabled", `---
enabled: false
---
`);

    const result = loadCustomAgents(tmpDir);
    const agent = result.get("disabled")!;
    expect(agent.enabled).toBe(false);
  });

  it("takes the agent type from frontmatter name, not the filename", () => {
    // Claude Code's rule: "the filename doesn't have to match". The same file
    // dropped into either tool must dispatch under the same type.
    writeAgent("blubb", `---
name: code-review
description: Reviews code
---

Agent prompt.`);

    const result = loadCustomAgents(tmpDir);
    expect(result.get("code-review")!.name).toBe("code-review");
    expect(result.get("blubb")).toBeUndefined();
  });

  it("records the file it was read from, not the one its type would name", () => {
    // `/agents` edits `sourcePath`: probing for `<type>.md` finds nothing here,
    // and its no-file branch writes a stub that loses to this file on load.
    writeAgent("blubb", `---
name: code-review
description: Reviews code
---

Agent prompt.`);

    expect(loadCustomAgents(tmpDir).get("code-review")!.sourcePath)
      .toBe(join(tmpDir, ".pi", "agents", "blubb.md"));
  });

  it("falls back to the filename for an empty or blank declared name", () => {
    // A quoted empty `name:` would otherwise register the agent under the empty
    // type — unspawnable, and it takes the filename-derived one down with it.
    writeAgent("myagent", "---\nname: \"\"\ndescription: My Agent\n---\n\nPrompt.");
    writeAgent("other", "---\nname: \"   \"\ndescription: Other\n---\n\nPrompt.");

    const result = loadCustomAgents(tmpDir);
    expect(result.get("myagent")!.name).toBe("myagent");
    expect(result.get("other")!.name).toBe("other");
    expect(result.has("")).toBe(false);
  });

  it("trims a declared name so it matches what the user meant to type", () => {
    writeAgent("blubb", "---\nname: \" code-review \"\ndescription: Reviews code\n---\n\nPrompt.");

    expect(loadCustomAgents(tmpDir).get("code-review")!.name).toBe("code-review");
  });

  it("falls back to the filename when no name is declared", () => {
    // Claude Code requires `name`; most existing files here predate it and
    // must keep loading under the identity they already dispatch by.
    writeAgent("myagent", `---
description: My Agent
---

Agent prompt.`);

    expect(loadCustomAgents(tmpDir).get("myagent")!.name).toBe("myagent");
  });

  it("keeps display_name as a label only, independent of the type", () => {
    writeAgent("blubb", `---
name: code-review
description: My Agent
display_name: MyAgent
---

Agent prompt.`);

    const agent = loadCustomAgents(tmpDir).get("code-review")!;
    expect(agent.name).toBe("code-review");
    expect(agent.displayName).toBe("MyAgent");
  });

  it("leaves displayName unset so the badge falls back to the type", () => {
    // A Claude Code file has no display_name; `getConfig` resolves the label
    // to the type, so it still badges as "code-reviewer" as it did before.
    writeAgent("whatever", `---
name: code-reviewer
description: Reviews code
color: "#8B5CF6"
---

Agent prompt.`);

    const agent = loadCustomAgents(tmpDir).get("code-reviewer")!;
    expect(agent.name).toBe("code-reviewer");
    expect(agent.displayName).toBeUndefined();
    expect(agent.color).toBe("#8B5CF6");
  });

  it("accepts a name Claude Code accepts, however unlike a type it looks", () => {
    // Its docs describe names as "lowercase letters and hyphens", but the only
    // load failure they state is the colon — so this must still load.
    writeAgent("reviewer", `---
name: Code Reviewer
description: Reviews code
---

Agent prompt.`);

    expect(loadCustomAgents(tmpDir).get("Code Reviewer")!.name).toBe("Code Reviewer");
  });

  it("refuses a name containing the plugin-scope separator", () => {
    // Claude Code doesn't load these. Skipping beats loading it under the
    // filename, which would dispatch an agent whose declared identity nothing
    // honoured.
    writeAgent("scoped", `---
name: my-plugin:reviewer
description: Reviews code
---

Agent prompt.`);

    const result = loadCustomAgents(tmpDir);
    expect(result.get("my-plugin:reviewer")).toBeUndefined();
    expect(result.get("scoped")).toBeUndefined();
  });

  it("does not claim the rejected file was overriding its filename's agent", () => {
    // It would have registered under its *declared* name, which a colon keeps
    // out of the registry entirely — so it shadowed nothing. Reporting a
    // substitution of the same-named file from another directory describes a
    // swap that never happened, and points at an agent that is unchanged.
    const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
    try {
      writeWorkspaceAgent("scoped", "---\ndescription: An unrelated agent\n---\n\nBody.");
      writeAgent("scoped", "---\nname: my-plugin:reviewer\ndescription: Reviews code\n---\n\nBody.");

      const result = loadCustomAgents(tmpDir);

      expect(result.get("scoped")?.description).toBe("An unrelated agent");
      const message = warn.mock.calls.map(args => String(args[0])).join("\n");
      expect(message).toContain("reserved for plugin-scoped identifiers");
      expect(message).not.toContain("now loads from");
    } finally {
      warn.mockRestore();
    }
  });

  it("lets a later file win a declared-name clash, as a filename clash always did", () => {
    // Filenames were unique per directory by construction; declared names are
    // not, so two files in one directory can now claim the same type.
    writeAgent("a-first", `---
name: shared
description: first
---

First.`);
    writeAgent("b-second", `---
name: shared
description: second
---

Second.`);

    const result = loadCustomAgents(tmpDir);
    expect(result.get("shared")!.description).toBe("second");
    expect([...result.keys()].filter(k => k === "shared")).toHaveLength(1);
  });

  it("parses disallowed_tools as csv list", () => {
    writeAgent("restricted", `---
description: Restricted Agent
disallowed_tools: bash, write
---

No bash or write.`);

    const result = loadCustomAgents(tmpDir);
    const agent = result.get("restricted")!;
    expect(agent.disallowedTools).toEqual(["bash", "write"]);
  });

  it("disallowed_tools defaults to undefined when omitted", () => {
    writeAgent("unrestricted", `---
description: Unrestricted
---

All tools.`);

    const result = loadCustomAgents(tmpDir);
    expect(result.get("unrestricted")!.disallowedTools).toBeUndefined();
  });

  it("parses memory scope", () => {
    writeAgent("rememberer", `---
description: Agent with memory
memory: project
---

Remember things.`);

    const result = loadCustomAgents(tmpDir);
    expect(result.get("rememberer")!.memory).toBe("project");
  });

  it("parses memory: user scope", () => {
    writeAgent("global-mem", `---
memory: user
---

User memory.`);

    const result = loadCustomAgents(tmpDir);
    expect(result.get("global-mem")!.memory).toBe("user");
  });

  it("memory defaults to undefined when omitted", () => {
    writeAgent("no-mem", `---
description: No memory
---

Stateless.`);

    const result = loadCustomAgents(tmpDir);
    expect(result.get("no-mem")!.memory).toBeUndefined();
  });

  it("rejects invalid memory scope", () => {
    writeAgent("bad-mem", `---
memory: invalid
---

Bad memory.`);

    const result = loadCustomAgents(tmpDir);
    expect(result.get("bad-mem")!.memory).toBeUndefined();
  });

  it("parses isolation: worktree", () => {
    writeAgent("isolated-wt", `---
description: Worktree agent
isolation: worktree
---

Isolated.`);

    const result = loadCustomAgents(tmpDir);
    expect(result.get("isolated-wt")!.isolation).toBe("worktree");
  });

  it("isolation defaults to undefined when omitted", () => {
    writeAgent("no-isolation", `---
description: Normal
---

Normal.`);

    const result = loadCustomAgents(tmpDir);
    expect(result.get("no-isolation")!.isolation).toBeUndefined();
  });

  it("rejects invalid isolation mode", () => {
    writeAgent("bad-isolation", `---
isolation: docker
---

Bad isolation.`);

    const result = loadCustomAgents(tmpDir);
    expect(result.get("bad-isolation")!.isolation).toBeUndefined();
  });

  // `isolation: off` is a veto, not a synonym for omitting the field: agent
  // config outranks tool-call params, so it turns a caller's "worktree" back
  // off. That is why it must survive parsing as "off" rather than undefined.
  it("parses isolation: off", () => {
    writeAgent("no-wt", `---
description: Never worktree
isolation: off
---

No worktree.`);

    const result = loadCustomAgents(tmpDir);
    expect(result.get("no-wt")!.isolation).toBe("off");
  });

  // pi's frontmatter parser is not YAML 1.1, so bare `off`/`no` stay strings
  // and only `false` becomes a boolean — accept the spellings an author is
  // likely to reach for rather than silently dropping them.
  it.each([
    ["false", "isolation: false"],
    ["none", "isolation: none"],
    ["no", "isolation: no"],
  ])("accepts %s as a spelling of off", (name, line) => {
    writeAgent(`off-${name}`, `---
${line}
---

Off.`);

    const result = loadCustomAgents(tmpDir);
    expect(result.get(`off-${name}`)!.isolation).toBe("off");
  });

  // A YAML error in one file used to escape loadFromDir and abort the whole
  // extension load — pi exited 1 before the TUI. Regression for #212.
  it("skips a file with malformed frontmatter and still loads the others", () => {
    // Unquoted `description` containing ": " — the shape Claude Code tolerates.
    writeAgent("broken", `---
name: broken
description: Use this: that
---

Broken body.`);
    writeAgent("good", `---
description: Still loads
---

Good body.`);

    const result = loadCustomAgents(tmpDir);

    expect(result.has("broken")).toBe(false);
    expect(result.get("good")?.description).toBe("Still loads");
  });

  it("names the offending file and the reason when skipping it", () => {
    const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
    try {
      writeAgent("broken", "---\nname: broken\ndescription: Use this: that\n---\n\nBroken body.");

      loadCustomAgents(tmpDir);

      const message = warn.mock.calls.map(args => String(args[0])).join("\n");
      expect(message).toContain(join(tmpDir, ".pi", "agents", "broken.md"));
      expect(message).toContain("Nested mappings are not allowed");
    } finally {
      warn.mockRestore();
    }
  });

  // Skipping an override is not the same as skipping an agent: the name still
  // resolves, to a different prompt, model and tool policy. Nothing downstream
  // can flag that, because the Agent call succeeds.
  it("warns when a skipped file was overriding an agent that stays resolvable", () => {
    const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
    try {
      writeWorkspaceAgent("dup", "---\ndescription: Earlier definition\n---\n\nEarlier body.");
      writeAgent("dup", "---\nname: dup\ndescription: Use this: that\n---\n\nBroken body.");

      const result = loadCustomAgents(tmpDir);

      expect(result.get("dup")?.description).toBe("Earlier definition");
      const message = warn.mock.calls.map(args => String(args[0])).join("\n");
      expect(message).toContain(`Agent "dup" now loads from ${join(tmpDir, ".agents", "agents", "dup.md")} instead`);
    } finally {
      warn.mockRestore();
    }
  });

  // A disabled agent does not dispatch (resolveEnabledTypeIn), so claiming the
  // name "still resolves" to it would send the user chasing the wrong file.
  it("does not claim a fallback when the shadowed definition is disabled", () => {
    const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
    try {
      writeWorkspaceAgent("dup", "---\ndescription: Earlier definition\nenabled: false\n---\n\nEarlier body.");
      writeAgent("dup", "---\nname: dup\ndescription: Use this: that\n---\n\nBroken body.");

      loadCustomAgents(tmpDir);

      const message = warn.mock.calls.map(args => String(args[0])).join("\n");
      expect(message).toContain("Skipping agent file");
      expect(message).not.toContain("now loads from");
    } finally {
      warn.mockRestore();
    }
  });

  it("does not claim a fallback when the skipped file overrode nothing", () => {
    const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
    try {
      writeAgent("lonely", "---\nname: lonely\ndescription: Use this: that\n---\n\nBroken body.");

      loadCustomAgents(tmpDir);

      const message = warn.mock.calls.map(args => String(args[0])).join("\n");
      expect(message).toContain("Skipping agent file");
      expect(message).not.toContain("now loads from");
    } finally {
      warn.mockRestore();
    }
  });

  // strictAgentFiles: opt in to failing closed rather than running a substitute.
  it("throws naming the file when strict, and skips it when not", () => {
    writeAgent("broken", "---\nname: broken\ndescription: Use this: that\n---\n\nBroken.");
    writeAgent("healthy", "---\ndescription: Fine\n---\n\nFine.");
    const brokenPath = join(tmpDir, ".pi", "agents", "broken.md");

    expect(() => loadCustomAgents(tmpDir, true)).toThrow(brokenPath);
    expect(() => loadCustomAgents(tmpDir, true)).toThrow("Nested mappings are not allowed");

    const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
    try {
      const result = loadCustomAgents(tmpDir);
      expect(result.has("broken")).toBe(false);
      expect(result.has("healthy")).toBe(true);
    } finally {
      warn.mockRestore();
    }
  });

  // The rule is "warn when it breaks, stay quiet while it stays broken".
  // Suppressing an unchanged problem must not suppress it forever.
  it("warns when a file breaks, not while it stays broken", () => {
    const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
    try {
      // Two loads while broken: the second must be suppressed as unchanged.
      writeAgent("flip", "---\nname: flip\ndescription: Use this: that\n---\n\nBroken.");
      loadCustomAgents(tmpDir);
      loadCustomAgents(tmpDir);
      expect(warn).toHaveBeenCalledTimes(1);

      writeAgent("flip", "---\ndescription: Fixed\n---\n\nFixed.");
      expect(loadCustomAgents(tmpDir).get("flip")?.description).toBe("Fixed");

      // Same breakage again — a new problem, not the one already reported.
      writeAgent("flip", "---\nname: flip\ndescription: Use this: that\n---\n\nBroken.");
      loadCustomAgents(tmpDir);

      expect(warn).toHaveBeenCalledTimes(2);
    } finally {
      warn.mockRestore();
    }
  });

  // Agents reload on every Agent call, so repeating would scribble a live TUI.
  it("warns once per message, not on every reload", () => {
    const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
    try {
      writeAgent("noisy", "---\nname: noisy\ndescription: Use this: that\n---\n\nBroken body.");

      loadCustomAgents(tmpDir);
      loadCustomAgents(tmpDir);
      loadCustomAgents(tmpDir);

      expect(warn).toHaveBeenCalledTimes(1);
    } finally {
      warn.mockRestore();
    }
  });

  it("honors PI_CODING_AGENT_DIR for global custom agent discovery", () => {
    const altAgentDir = mkdtempSync(join(tmpdir(), "pi-alt-agent-"));
    const originalEnv = process.env.PI_CODING_AGENT_DIR;
    process.env.PI_CODING_AGENT_DIR = altAgentDir;
    try {
      const globalAgentsDir = join(altAgentDir, "agents");
      mkdirSync(globalAgentsDir, { recursive: true });
      writeFileSync(
        join(globalAgentsDir, "via-env.md"),
        "---\ndescription: Discovered via env var\n---\n\nTest body.",
      );

      const result = loadCustomAgents(tmpDir);

      // Agent is found at $PI_CODING_AGENT_DIR/agents, not at $HOME/.pi/agent/agents
      expect(result.has("via-env")).toBe(true);
      expect(result.get("via-env")!.description).toBe("Discovered via env var");
    } finally {
      if (originalEnv == null) delete process.env.PI_CODING_AGENT_DIR;
      else process.env.PI_CODING_AGENT_DIR = originalEnv;
      rmSync(altAgentDir, { recursive: true, force: true });
    }
  });

  // `/agents → Eject` writes an AgentConfig back out as frontmatter. That writer
  // and this loader are the two halves of one format, but nothing pinned them
  // together — so a field can serialize to something the loader reads back
  // differently, and the agent silently changes shape on eject.
  describe("eject round-trip", () => {
    function roundTrip(cfg: Partial<AgentConfig>) {
      const full: AgentConfig = {
        description: "Round trip agent",
        systemPrompt: "Body prompt.",
        promptMode: "append",
        ...cfg,
      } as AgentConfig;
      writeAgent("rt", serializeAgentFile(full));
      const loaded = loadCustomAgents(tmpDir).get("rt");
      expect(loaded).toBeDefined();
      return loaded!;
    }

    it("preserves an explicitly narrowed tool list", () => {
      expect(roundTrip({ builtinToolNames: ["read", "grep"] }).builtinToolNames).toEqual(["read", "grep"]);
    });

    it("preserves the full built-in set", () => {
      expect(roundTrip({ builtinToolNames: [...BUILTIN_TOOL_NAMES] }).builtinToolNames)
        .toEqual([...BUILTIN_TOOL_NAMES]);
    });

    it("preserves an empty tool list instead of widening it to every built-in", () => {
      // `tools: none` parses to [] on load, so ejecting an agent with zero
      // built-ins must not write `tools: all` — that hands it the whole toolbox.
      expect(roundTrip({ builtinToolNames: [] }).builtinToolNames).toEqual([]);
    });

    it("preserves the scalar and list fields it writes", () => {
      const loaded = roundTrip({
        displayName: "RT",
        model: "anthropic/claude-haiku-4-5",
        thinking: "low",
        maxTurns: 7,
        allowedSubagents: ["Explore"],
        excludeExtensions: ["ext-beta"],
        disallowedTools: ["write"],
        inheritContext: true,
        runInBackground: true,
        outputTranscript: false,
        isolated: true,
        memory: "project",
        isolation: "worktree",
      });
      expect(loaded.displayName).toBe("RT");
      expect(loaded.model).toBe("anthropic/claude-haiku-4-5");
      expect(loaded.thinking).toBe("low");
      expect(loaded.maxTurns).toBe(7);
      expect(loaded.allowedSubagents).toEqual(["Explore"]);
      expect(loaded.excludeExtensions).toEqual(["ext-beta"]);
      expect(loaded.disallowedTools).toEqual(["write"]);
      expect(loaded.inheritContext).toBe(true);
      expect(loaded.runInBackground).toBe(true);
      expect(loaded.outputTranscript).toBe(false);
      expect(loaded.isolated).toBe(true);
      expect(loaded.memory).toBe("project");
      expect(loaded.isolation).toBe("worktree");
    });

    // The writer used to emit `run_in_background` only when truthy, so an
    // explicit `false` was dropped. Harmless while foreground was the default
    // and omission meant the same thing — but with `backgroundByDefault` on,
    // dropping it flips the ejected agent to background.
    it("preserves an explicit run_in_background: false instead of dropping it", () => {
      expect(roundTrip({ runInBackground: false }).runInBackground).toBe(false);
    });

    it("leaves run_in_background unset when the config doesn't pin it", () => {
      // Absent must stay absent — writing a value would freeze the agent
      // against the setting rather than letting it follow the default.
      expect(roundTrip({}).runInBackground).toBeUndefined();
    });

    it("preserves the extension and skill list fields", () => {
      // These serialize as bare CSV and are re-parsed by parseExtensionsSpec /
      // the skills field. A generate/parse mismatch here is silent: the ejected
      // agent loads fine but with a different extension or skill scope than the
      // one that was ejected.
      const loaded = roundTrip({
        extensions: ["mcp", "pi-notify"],
        skills: ["planning", "review"],
        disallowedTools: ["write", "edit"],
      });
      expect(loaded.extensions).toEqual(["mcp", "pi-notify"]);
      expect(loaded.skills).toEqual(["planning", "review"]);
      expect(loaded.disallowedTools).toEqual(["write", "edit"]);
    });

    it("preserves the boolean forms of extensions and skills", () => {
      const off = roundTrip({ extensions: false, skills: false });
      expect(off.extensions).toBe(false);
      expect(off.skills).toBe(false);
    });

    it("preserves allowed_subagents in both its list and `all` forms", () => {
      expect(roundTrip({ allowedSubagents: "all" }).allowedSubagents).toBe("all");
      expect(roundTrip({ allowedSubagents: ["Explore", "Plan"] }).allowedSubagents)
        .toEqual(["Explore", "Plan"]);
    });

    it("preserves a description containing a colon", () => {
      // Serialized via JSON.stringify precisely so YAML doesn't split on the colon.
      expect(roundTrip({ description: "Scout: find things" }).description).toBe("Scout: find things");
    });
  });
});
