import { existsSync, mkdirSync, mkdtempSync, readFileSync, 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 {
  applyAndEmitLoaded,
  applySettings,
  loadSettings,
  persistToastFor,
  type SettingsAppliers,
  saveAndEmitChanged,
  saveSettings,
} from "../src/settings.js";

/**
 * Tests for persistent settings. Uses two tmp directories:
 * - `globalDir`: redirected via PI_CODING_AGENT_DIR so getAgentDir() returns it.
 *   Simulates `~/.pi/agent/` — the global scope.
 * - `projectDir`: passed explicitly as cwd to load/save.
 *   Simulates the user's project root. Settings live at `<projectDir>/.pi/subagents.json`.
 */
describe("settings persistence", () => {
  let globalDir: string;
  let projectDir: string;
  let originalAgentDirEnv: string | undefined;

  const globalFile = () => join(globalDir, "subagents.json");
  const projectFile = () => join(projectDir, ".pi", "subagents.json");

  beforeEach(() => {
    globalDir = mkdtempSync(join(tmpdir(), "pi-settings-global-"));
    projectDir = mkdtempSync(join(tmpdir(), "pi-settings-project-"));
    originalAgentDirEnv = process.env.PI_CODING_AGENT_DIR;
    process.env.PI_CODING_AGENT_DIR = globalDir;
  });

  afterEach(() => {
    if (originalAgentDirEnv == null) delete process.env.PI_CODING_AGENT_DIR;
    else process.env.PI_CODING_AGENT_DIR = originalAgentDirEnv;
    rmSync(globalDir, { recursive: true, force: true });
    rmSync(projectDir, { recursive: true, force: true });
  });

  function writeGlobal(obj: unknown) {
    writeFileSync(globalFile(), JSON.stringify(obj));
  }

  function writeProject(obj: unknown) {
    mkdirSync(join(projectDir, ".pi"), { recursive: true });
    writeFileSync(projectFile(), JSON.stringify(obj));
  }

  it("returns {} when both files are missing", () => {
    expect(loadSettings(projectDir)).toEqual({});
  });

  it("returns {} when both files are malformed JSON", () => {
    writeFileSync(globalFile(), "not json {{");
    mkdirSync(join(projectDir, ".pi"), { recursive: true });
    writeFileSync(projectFile(), "also not json");
    expect(loadSettings(projectDir)).toEqual({});
  });

  it("loads from global when no project file", () => {
    writeGlobal({ maxConcurrent: 16, graceTurns: 10 });
    expect(loadSettings(projectDir)).toEqual({ maxConcurrent: 16, graceTurns: 10 });
  });

  it("loads from project when no global file", () => {
    writeProject({ maxConcurrent: 8, defaultJoinMode: "group" });
    expect(loadSettings(projectDir)).toEqual({ maxConcurrent: 8, defaultJoinMode: "group" });
  });

  it("merges global + project with project winning on conflicts", () => {
    writeGlobal({ maxConcurrent: 16, graceTurns: 10, defaultJoinMode: "async" });
    writeProject({ maxConcurrent: 4, defaultMaxTurns: 50 });
    expect(loadSettings(projectDir)).toEqual({
      maxConcurrent: 4, // project wins
      graceTurns: 10, // from global
      defaultJoinMode: "async", // from global
      defaultMaxTurns: 50, // from project only
    });
  });

  it("round-trips values: saveSettings then loadSettings", () => {
    const settings = {
      maxConcurrent: 7,
      defaultMaxTurns: 30,
      graceTurns: 3,
      defaultJoinMode: "smart" as const,
      schedulingEnabled: false,
      toolDescriptionMode: "compact" as const,
    };
    saveSettings(settings, projectDir);
    expect(loadSettings(projectDir)).toEqual(settings);
  });

  it("round-trips schedulingEnabled (true and false), and absence stays absent", () => {
    saveSettings({ schedulingEnabled: false }, projectDir);
    expect(loadSettings(projectDir)).toEqual({ schedulingEnabled: false });

    saveSettings({ schedulingEnabled: true }, projectDir);
    expect(loadSettings(projectDir)).toEqual({ schedulingEnabled: true });

    // Absence — caller's "use default" signal — must not become a stored false.
    saveSettings({}, projectDir);
    expect(loadSettings(projectDir)).toEqual({});
  });

  it("round-trips fleetView (true and false); keeps boolean, drops non-boolean", () => {
    saveSettings({ fleetView: false }, projectDir);
    expect(loadSettings(projectDir)).toEqual({ fleetView: false });
    saveSettings({ fleetView: true }, projectDir);
    expect(loadSettings(projectDir)).toEqual({ fleetView: true });
    writeProject({ fleetView: "on" } as any);
    expect(loadSettings(projectDir)).toEqual({}); // non-boolean dropped
  });

  it("round-trips agentMentions modes; drops an unknown one", () => {
    for (const mode of ["model", "direct", "off"] as const) {
      saveSettings({ agentMentions: mode }, projectDir);
      expect(loadSettings(projectDir)).toEqual({ agentMentions: mode });
    }
    writeProject({ agentMentions: "on" } as any);
    expect(loadSettings(projectDir)).toEqual({}); // unknown mode dropped
  });

  it("reads the pre-mode agentMentions booleans as their modes", () => {
    // The setting shipped as a boolean before `model` existed, so a config
    // written then — or hand-written from the old README — must keep working.
    // `true` meant "on", and on is now `model`.
    writeProject({ agentMentions: true } as any);
    expect(loadSettings(projectDir)).toEqual({ agentMentions: "model" });
    writeProject({ agentMentions: false } as any);
    expect(loadSettings(projectDir)).toEqual({ agentMentions: "off" });
  });

  it("round-trips rememberAgents (true and false); keeps boolean, drops non-boolean", () => {
    saveSettings({ rememberAgents: false }, projectDir);
    expect(loadSettings(projectDir)).toEqual({ rememberAgents: false });
    saveSettings({ rememberAgents: true }, projectDir);
    expect(loadSettings(projectDir)).toEqual({ rememberAgents: true });
    writeProject({ rememberAgents: "on" } as any);
    expect(loadSettings(projectDir)).toEqual({}); // non-boolean dropped
  });

  it("round-trips widgetMode; keeps valid values, drops invalid", () => {
    saveSettings({ widgetMode: "off" }, projectDir);
    expect(loadSettings(projectDir)).toEqual({ widgetMode: "off" });
    saveSettings({ widgetMode: "background" }, projectDir);
    expect(loadSettings(projectDir)).toEqual({ widgetMode: "background" });
    writeProject({ widgetMode: "sideways" } as any);
    expect(loadSettings(projectDir)).toEqual({}); // invalid value dropped
  });

  it("round-trips outputTranscript; drops non-boolean", () => {
    saveSettings({ outputTranscript: false }, projectDir);
    expect(loadSettings(projectDir)).toEqual({ outputTranscript: false });
    saveSettings({ outputTranscript: true }, projectDir);
    expect(loadSettings(projectDir)).toEqual({ outputTranscript: true });
    writeProject({ outputTranscript: "no" } as any);
    expect(loadSettings(projectDir)).toEqual({}); // non-boolean dropped
  });

  it("round-trips backgroundByDefault (true and false), and absence stays absent", () => {
    // `false` is the load-bearing case: it's how a user restores the previous
    // foreground default, so it must survive a save/load rather than being
    // read back as absent and re-defaulting to background.
    saveSettings({ backgroundByDefault: false }, projectDir);
    expect(loadSettings(projectDir)).toEqual({ backgroundByDefault: false });

    saveSettings({ backgroundByDefault: true }, projectDir);
    expect(loadSettings(projectDir)).toEqual({ backgroundByDefault: true });

    saveSettings({}, projectDir);
    expect(loadSettings(projectDir)).toEqual({});
  });

  it("sanitize drops non-boolean backgroundByDefault silently", () => {
    writeProject({ backgroundByDefault: "yes" } as any);
    expect(loadSettings(projectDir)).toEqual({});
    writeProject({ backgroundByDefault: 0 } as any);
    expect(loadSettings(projectDir)).toEqual({});
  });

  it("round-trips worktreeIsolation; drops non-boolean", () => {
    saveSettings({ worktreeIsolation: false }, projectDir);
    expect(loadSettings(projectDir)).toEqual({ worktreeIsolation: false });
    saveSettings({ worktreeIsolation: true }, projectDir);
    expect(loadSettings(projectDir)).toEqual({ worktreeIsolation: true });
    writeProject({ worktreeIsolation: "off" } as any);
    expect(loadSettings(projectDir)).toEqual({}); // non-boolean dropped
  });

  it("round-trips reportUsage and showCost; drops non-boolean", () => {
    saveSettings({ reportUsage: true, showCost: true }, projectDir);
    expect(loadSettings(projectDir)).toEqual({ reportUsage: true, showCost: true });
    saveSettings({ reportUsage: false, showCost: false }, projectDir);
    expect(loadSettings(projectDir)).toEqual({ reportUsage: false, showCost: false });
    // The sanitizer is an allowlist: a key it does not name is dropped, and the
    // setting silently never applies.
    writeProject({ reportUsage: "on", showCost: 1 } as any);
    expect(loadSettings(projectDir)).toEqual({});
  });

  it("sanitize drops non-boolean schedulingEnabled silently", async () => {
    writeProject({ schedulingEnabled: "yes" } as any);
    expect(loadSettings(projectDir)).toEqual({});
    writeProject({ schedulingEnabled: 1 } as any);
    expect(loadSettings(projectDir)).toEqual({});
  });

  it("saveSettings writes only to the project file; global is untouched", () => {
    writeGlobal({ maxConcurrent: 16 });
    saveSettings({ maxConcurrent: 2 }, projectDir);

    // Project file contains the new value
    expect(JSON.parse(readFileSync(projectFile(), "utf-8"))).toEqual({ maxConcurrent: 2 });
    // Global file unchanged
    expect(JSON.parse(readFileSync(globalFile(), "utf-8"))).toEqual({ maxConcurrent: 16 });
  });

  it("saveSettings creates <cwd>/.pi/ when missing", () => {
    expect(existsSync(join(projectDir, ".pi"))).toBe(false);
    saveSettings({ maxConcurrent: 4 }, projectDir);
    expect(existsSync(projectFile())).toBe(true);
  });

  it("round-trips defaultMaxTurns: 0 (unlimited marker)", () => {
    saveSettings({ defaultMaxTurns: 0 }, projectDir);
    expect(loadSettings(projectDir)).toEqual({ defaultMaxTurns: 0 });
  });

  it("ignores unknown extra fields on load (forward-compat)", () => {
    writeProject({ maxConcurrent: 2, futureField: "ignored" });
    const loaded = loadSettings(projectDir);
    expect(loaded.maxConcurrent).toBe(2);
    // Unknown fields are stripped by the sanitizer — old versions won't persist garbage
    expect((loaded as Record<string, unknown>).futureField).toBeUndefined();
  });

  it("composes partial global + partial project correctly", () => {
    writeGlobal({ graceTurns: 10 });
    writeProject({ maxConcurrent: 2 });
    expect(loadSettings(projectDir)).toEqual({ graceTurns: 10, maxConcurrent: 2 });
  });

  describe("sanitizer", () => {
    it("drops maxConcurrent < 1", () => {
      writeProject({ maxConcurrent: 0, graceTurns: 5 });
      expect(loadSettings(projectDir)).toEqual({ graceTurns: 5 });
    });

    it("drops negative maxConcurrent", () => {
      writeProject({ maxConcurrent: -3 });
      expect(loadSettings(projectDir)).toEqual({});
    });

    it("drops non-integer maxConcurrent (floats, NaN, strings)", () => {
      writeProject({ maxConcurrent: 3.5 });
      expect(loadSettings(projectDir).maxConcurrent).toBeUndefined();
      writeProject({ maxConcurrent: "four" });
      expect(loadSettings(projectDir).maxConcurrent).toBeUndefined();
      writeProject({ maxConcurrent: null });
      expect(loadSettings(projectDir).maxConcurrent).toBeUndefined();
    });

    it("accepts defaultMaxTurns: 0 (explicit unlimited)", () => {
      writeProject({ defaultMaxTurns: 0 });
      expect(loadSettings(projectDir)).toEqual({ defaultMaxTurns: 0 });
    });

    it("drops negative defaultMaxTurns", () => {
      writeProject({ defaultMaxTurns: -1 });
      expect(loadSettings(projectDir)).toEqual({});
    });

    it("drops graceTurns < 1", () => {
      writeProject({ graceTurns: 0 });
      expect(loadSettings(projectDir)).toEqual({});
    });

    it("keeps maxSubagentDepth 0 (nesting off) but drops negative, fractional, and over-ceiling values", () => {
      writeProject({ maxSubagentDepth: 0 });
      expect(loadSettings(projectDir)).toEqual({ maxSubagentDepth: 0 });
      writeProject({ maxSubagentDepth: -1 });
      expect(loadSettings(projectDir)).toEqual({});
      writeProject({ maxSubagentDepth: 1.5 });
      expect(loadSettings(projectDir)).toEqual({});
      writeProject({ maxSubagentDepth: 17 });
      expect(loadSettings(projectDir)).toEqual({});
    });

    it("accepts `none` and `false` as the disabled fallback, nothing else", () => {
      // Only the boolean needs an alias: it would otherwise be dropped, leaving
      // the PERMISSIVE default while the author believed strict was on. Every
      // string stays an agent name, so a mistaken "off" fails loudly at dispatch
      // instead of meaning one thing here and another in the resolver.
      for (const spelling of ["none", "NONE", " none ", false]) {
        writeProject({ fallbackSubagent: spelling });
        expect(loadSettings(projectDir).fallbackSubagent?.toLowerCase()).toBe("none");
      }
      writeProject({ fallbackSubagent: "off" });
      expect(loadSettings(projectDir)).toEqual({ fallbackSubagent: "off" });
    });

    it("drops values that aren't a string or `false`, without coercing them", () => {
      // String(["none"]) is "none" — coercing would silently enable strict mode.
      for (const junk of [["none"], null, 42, true, {}]) {
        writeProject({ fallbackSubagent: junk });
        expect(loadSettings(projectDir)).toEqual({});
      }
    });

    it("keeps a named fallback agent and drops non-strings", () => {
      writeProject({ fallbackSubagent: "  my-router  " });
      expect(loadSettings(projectDir)).toEqual({ fallbackSubagent: "my-router" });
      writeProject({ fallbackSubagent: 42 });
      expect(loadSettings(projectDir)).toEqual({});
      writeProject({ fallbackSubagent: "   " });
      expect(loadSettings(projectDir)).toEqual({});
    });

    it("drops invalid defaultJoinMode values", () => {
      writeProject({ defaultJoinMode: "invalid" });
      expect(loadSettings(projectDir)).toEqual({});
      writeProject({ defaultJoinMode: 42 });
      expect(loadSettings(projectDir)).toEqual({});
      writeProject({ defaultJoinMode: "" });
      expect(loadSettings(projectDir)).toEqual({});
    });

    it("accepts all three valid join modes", () => {
      for (const mode of ["async", "group", "smart"] as const) {
        writeProject({ defaultJoinMode: mode });
        expect(loadSettings(projectDir)).toEqual({ defaultJoinMode: mode });
      }
    });

    it("accepts scopeModels boolean (true and false)", () => {
      writeProject({ scopeModels: true });
      expect(loadSettings(projectDir)).toEqual({ scopeModels: true });
      writeProject({ scopeModels: false });
      expect(loadSettings(projectDir)).toEqual({ scopeModels: false });
    });

    it("accepts strictAgentFiles boolean (true and false)", () => {
      writeProject({ strictAgentFiles: true });
      expect(loadSettings(projectDir)).toEqual({ strictAgentFiles: true });
      writeProject({ strictAgentFiles: false });
      expect(loadSettings(projectDir)).toEqual({ strictAgentFiles: false });
    });

    it("drops non-boolean strictAgentFiles", () => {
      writeProject({ strictAgentFiles: "yes" });
      expect(loadSettings(projectDir).strictAgentFiles).toBeUndefined();
      writeProject({ strictAgentFiles: 1 });
      expect(loadSettings(projectDir).strictAgentFiles).toBeUndefined();
    });

    it("drops non-boolean scopeModels", () => {
      writeProject({ scopeModels: "yes" });
      expect(loadSettings(projectDir).scopeModels).toBeUndefined();
      writeProject({ scopeModels: 1 });
      expect(loadSettings(projectDir).scopeModels).toBeUndefined();
      writeProject({ scopeModels: null });
      expect(loadSettings(projectDir).scopeModels).toBeUndefined();
    });

    it("accepts disableDefaultAgents boolean (true and false)", () => {
      writeProject({ disableDefaultAgents: true });
      expect(loadSettings(projectDir)).toEqual({ disableDefaultAgents: true });
      writeProject({ disableDefaultAgents: false });
      expect(loadSettings(projectDir)).toEqual({ disableDefaultAgents: false });
    });

    it("drops non-boolean disableDefaultAgents", () => {
      writeProject({ disableDefaultAgents: "yes" });
      expect(loadSettings(projectDir).disableDefaultAgents).toBeUndefined();
      writeProject({ disableDefaultAgents: 1 });
      expect(loadSettings(projectDir).disableDefaultAgents).toBeUndefined();
      writeProject({ disableDefaultAgents: null });
      expect(loadSettings(projectDir).disableDefaultAgents).toBeUndefined();
    });

    it("accepts all valid toolDescriptionMode values", () => {
      for (const mode of ["full", "compact", "custom"] as const) {
        writeProject({ toolDescriptionMode: mode });
        expect(loadSettings(projectDir)).toEqual({ toolDescriptionMode: mode });
      }
    });

    it("drops invalid toolDescriptionMode", () => {
      writeProject({ toolDescriptionMode: "tiny" });
      expect(loadSettings(projectDir).toolDescriptionMode).toBeUndefined();
      writeProject({ toolDescriptionMode: true });
      expect(loadSettings(projectDir).toolDescriptionMode).toBeUndefined();
      writeProject({ toolDescriptionMode: null });
      expect(loadSettings(projectDir).toolDescriptionMode).toBeUndefined();
    });

    it("returns {} when the JSON root is not an object (array, string, null)", () => {
      mkdirSync(join(projectDir, ".pi"), { recursive: true });
      writeFileSync(projectFile(), '["not", "an", "object"]');
      expect(loadSettings(projectDir)).toEqual({});
      writeFileSync(projectFile(), '"just a string"');
      expect(loadSettings(projectDir)).toEqual({});
      writeFileSync(projectFile(), "null");
      expect(loadSettings(projectDir)).toEqual({});
    });

    it("keeps valid fields while dropping invalid siblings", () => {
      writeProject({
        maxConcurrent: 4, // ok
        defaultMaxTurns: -5, // dropped
        graceTurns: 3, // ok
        defaultJoinMode: "nope", // dropped
      });
      expect(loadSettings(projectDir)).toEqual({ maxConcurrent: 4, graceTurns: 3 });
    });

    it("accepts values at the ceiling (maxConcurrent=1024, defaultMaxTurns=10000, graceTurns=1000)", () => {
      writeProject({ maxConcurrent: 1024, defaultMaxTurns: 10_000, graceTurns: 1_000 });
      expect(loadSettings(projectDir)).toEqual({
        maxConcurrent: 1024,
        defaultMaxTurns: 10_000,
        graceTurns: 1_000,
      });
    });

    it("drops values above the ceiling", () => {
      writeProject({ maxConcurrent: 1025 });
      expect(loadSettings(projectDir).maxConcurrent).toBeUndefined();
      writeProject({ defaultMaxTurns: 10_001 });
      expect(loadSettings(projectDir).defaultMaxTurns).toBeUndefined();
      writeProject({ graceTurns: 1_001 });
      expect(loadSettings(projectDir).graceTurns).toBeUndefined();
    });

    it("drops absurdly large values (e.g. 1e6)", () => {
      writeProject({ maxConcurrent: 1_000_000, defaultMaxTurns: 1_000_000, graceTurns: 1_000_000 });
      expect(loadSettings(projectDir)).toEqual({});
    });
  });

  describe("save result + corrupt-file warning", () => {
    it("saveSettings returns true on success", () => {
      expect(saveSettings({ maxConcurrent: 2 }, projectDir)).toBe(true);
      expect(JSON.parse(readFileSync(projectFile(), "utf-8"))).toEqual({ maxConcurrent: 2 });
    });

    it("saveSettings returns false when the target dir cannot be created", () => {
      // Place a regular file where the parent of the settings file would go —
      // mkdirSync + writeFileSync both fail with ENOTDIR / EEXIST.
      const filePosingAsCwd = join(tmpdir(), `pi-settings-notdir-${Date.now()}`);
      writeFileSync(filePosingAsCwd, "");
      try {
        expect(saveSettings({ maxConcurrent: 1 }, filePosingAsCwd)).toBe(false);
      } finally {
        rmSync(filePosingAsCwd, { force: true });
      }
    });

    it("warns to console.warn when an existing file is malformed", () => {
      const spy = vi.spyOn(console, "warn").mockImplementation(() => {});
      mkdirSync(join(projectDir, ".pi"), { recursive: true });
      writeFileSync(projectFile(), "not valid json {{{");
      try {
        expect(loadSettings(projectDir)).toEqual({});
        expect(spy).toHaveBeenCalledTimes(1);
        expect(String(spy.mock.calls[0][0])).toMatch(/Ignoring malformed settings/);
      } finally {
        spy.mockRestore();
      }
    });

    it("does NOT warn when a file is simply missing", () => {
      const spy = vi.spyOn(console, "warn").mockImplementation(() => {});
      try {
        expect(loadSettings(projectDir)).toEqual({});
        expect(spy).not.toHaveBeenCalled();
      } finally {
        spy.mockRestore();
      }
    });
  });

  describe("applySettings", () => {
    let appliers: SettingsAppliers;

    beforeEach(() => {
      appliers = {
        setMaxConcurrent: vi.fn(),
        setDefaultMaxTurns: vi.fn(),
        setGraceTurns: vi.fn(),
        setDefaultJoinMode: vi.fn(),
        setBackgroundByDefault: vi.fn(),
        setSchedulingEnabled: vi.fn(),
        setScopeModels: vi.fn(),
        setStrictAgentFiles: vi.fn(),
        setDisableDefaultAgents: vi.fn(),
        setToolDescriptionMode: vi.fn(),
        setFleetView: vi.fn(),
        setAgentMentions: vi.fn(),
      setRememberAgents: vi.fn(),
        setWidgetMode: vi.fn(),
        setOutputTranscript: vi.fn(),
        setWorktreeIsolation: vi.fn(),
        setMaxSubagentDepth: vi.fn(),
        setFallbackSubagent: vi.fn(),
        setReportUsage: vi.fn(),
        setShowCost: vi.fn(),
      };
    });

    it("applies reportUsage and showCost", () => {
      applySettings({ reportUsage: true, showCost: true }, appliers);
      expect(appliers.setReportUsage).toHaveBeenCalledWith(true);
      expect(appliers.setShowCost).toHaveBeenCalledWith(true);

      applySettings({ reportUsage: false, showCost: false }, appliers);
      expect(appliers.setReportUsage).toHaveBeenCalledWith(false);
      expect(appliers.setShowCost).toHaveBeenCalledWith(false);
    });

    it("is a no-op on an empty settings object", () => {
      applySettings({}, appliers);
      expect(appliers.setReportUsage).not.toHaveBeenCalled();
      expect(appliers.setShowCost).not.toHaveBeenCalled();
      expect(appliers.setMaxConcurrent).not.toHaveBeenCalled();
      expect(appliers.setDefaultMaxTurns).not.toHaveBeenCalled();
      expect(appliers.setGraceTurns).not.toHaveBeenCalled();
      expect(appliers.setDefaultJoinMode).not.toHaveBeenCalled();
      expect(appliers.setSchedulingEnabled).not.toHaveBeenCalled();
      expect(appliers.setScopeModels).not.toHaveBeenCalled();
      expect(appliers.setDisableDefaultAgents).not.toHaveBeenCalled();
      expect(appliers.setToolDescriptionMode).not.toHaveBeenCalled();
    });

    it("applies fallbackSubagent through to the registry", () => {
      // Without this, deleting the applySettings line for this field leaves the
      // whole suite green while `subagents.json` silently stops working.
      applySettings({ fallbackSubagent: "none" }, appliers);
      expect(appliers.setFallbackSubagent).toHaveBeenCalledWith("none");
    });

    it("applies only the fields that are present", () => {
      applySettings({ maxConcurrent: 4, graceTurns: 3, maxSubagentDepth: 1 }, appliers);
      expect(appliers.setMaxConcurrent).toHaveBeenCalledWith(4);
      expect(appliers.setGraceTurns).toHaveBeenCalledWith(3);
      expect(appliers.setMaxSubagentDepth).toHaveBeenCalledWith(1);
      expect(appliers.setDefaultMaxTurns).not.toHaveBeenCalled();
      expect(appliers.setDefaultJoinMode).not.toHaveBeenCalled();
      expect(appliers.setSchedulingEnabled).not.toHaveBeenCalled();
      expect(appliers.setScopeModels).not.toHaveBeenCalled();
    });

    it("applies all fields when all are present", () => {
      applySettings(
        {
          maxConcurrent: 8,
          defaultMaxTurns: 50,
          graceTurns: 7,
          defaultJoinMode: "group",
          schedulingEnabled: false,
          scopeModels: true,
          disableDefaultAgents: true,
          toolDescriptionMode: "compact",
          fleetView: false,
          widgetMode: "off",
        },
        appliers,
      );
      expect(appliers.setMaxConcurrent).toHaveBeenCalledWith(8);
      expect(appliers.setDefaultMaxTurns).toHaveBeenCalledWith(50);
      expect(appliers.setGraceTurns).toHaveBeenCalledWith(7);
      expect(appliers.setDefaultJoinMode).toHaveBeenCalledWith("group");
      expect(appliers.setSchedulingEnabled).toHaveBeenCalledWith(false);
      expect(appliers.setScopeModels).toHaveBeenCalledWith(true);
      expect(appliers.setStrictAgentFiles).not.toHaveBeenCalled();  // absent from this snapshot
      expect(appliers.setDisableDefaultAgents).toHaveBeenCalledWith(true);
      expect(appliers.setToolDescriptionMode).toHaveBeenCalledWith("compact");
      expect(appliers.setFleetView).toHaveBeenCalledWith(false);
      expect(appliers.setWidgetMode).toHaveBeenCalledWith("off");
    });

    it("applies strictAgentFiles; skips it when absent", () => {
      applySettings({ strictAgentFiles: true }, appliers);
      expect(appliers.setStrictAgentFiles).toHaveBeenCalledWith(true);
      applySettings({}, appliers);
      expect(appliers.setStrictAgentFiles).toHaveBeenCalledTimes(1);
    });

    it("applies widgetMode; skips it when absent", () => {
      applySettings({ widgetMode: "off" }, appliers);
      expect(appliers.setWidgetMode).toHaveBeenCalledWith("off");
      applySettings({}, appliers);
      expect(appliers.setWidgetMode).toHaveBeenCalledTimes(1); // absence is "use default"
    });

    it("applies fleetView (true and false); skips it when absent", () => {
      applySettings({ fleetView: true }, appliers);
      expect(appliers.setFleetView).toHaveBeenCalledWith(true);
      applySettings({}, appliers);
      expect(appliers.setFleetView).toHaveBeenCalledTimes(1); // absence is "use default"
    });

    it("applies agentMentions; skips it when absent", () => {
      applySettings({ agentMentions: "direct" }, appliers);
      expect(appliers.setAgentMentions).toHaveBeenCalledWith("direct");
      applySettings({}, appliers);
      expect(appliers.setAgentMentions).toHaveBeenCalledTimes(1); // absence is "use default"
    });

    it("applies rememberAgents; skips it when absent", () => {
      applySettings({ rememberAgents: false }, appliers);
      expect(appliers.setRememberAgents).toHaveBeenCalledWith(false);
      applySettings({}, appliers);
      expect(appliers.setRememberAgents).toHaveBeenCalledTimes(1); // absence is "use default"
    });

    it("applies scopeModels: false", () => {
      applySettings({ scopeModels: false }, appliers);
      expect(appliers.setScopeModels).toHaveBeenCalledWith(false);
    });

    it("applies disableDefaultAgents: false", () => {
      applySettings({ disableDefaultAgents: false }, appliers);
      expect(appliers.setDisableDefaultAgents).toHaveBeenCalledWith(false);
    });

    it("applies toolDescriptionMode", () => {
      applySettings({ toolDescriptionMode: "full" }, appliers);
      expect(appliers.setToolDescriptionMode).toHaveBeenCalledWith("full");
    });

    it("applies outputTranscript (both true and false)", () => {
      applySettings({ outputTranscript: false }, appliers);
      expect(appliers.setOutputTranscript).toHaveBeenCalledWith(false);
      applySettings({ outputTranscript: true }, appliers);
      expect(appliers.setOutputTranscript).toHaveBeenCalledWith(true);
    });

    it("applies worktreeIsolation (both true and false)", () => {
      applySettings({ worktreeIsolation: false }, appliers);
      expect(appliers.setWorktreeIsolation).toHaveBeenCalledWith(false);
      applySettings({ worktreeIsolation: true }, appliers);
      expect(appliers.setWorktreeIsolation).toHaveBeenCalledWith(true);
    });

    it("applies defaultMaxTurns: 0 as the explicit unlimited marker", () => {
      applySettings({ defaultMaxTurns: 0 }, appliers);
      expect(appliers.setDefaultMaxTurns).toHaveBeenCalledWith(0);
    });

    it("calls setBackgroundByDefault with either boolean", () => {
      applySettings({ backgroundByDefault: false }, appliers);
      expect(appliers.setBackgroundByDefault).toHaveBeenCalledWith(false);
      applySettings({ backgroundByDefault: true }, appliers);
      expect(appliers.setBackgroundByDefault).toHaveBeenCalledWith(true);
    });

    // Absence must leave the in-memory default (background) alone — calling
    // the applier with `undefined` would read as foreground at the spawn site.
    it("does not call setBackgroundByDefault when the field is absent", () => {
      applySettings({ maxConcurrent: 4 }, appliers);
      expect(appliers.setBackgroundByDefault).not.toHaveBeenCalled();
    });

    // Wiring tests for the master switch — ensures the schedulingEnabled
    // field flows from the parsed settings into the applier callback that
    // sets the in-memory flag in index.ts.
    it("calls setSchedulingEnabled(true) when schedulingEnabled is true", () => {
      applySettings({ schedulingEnabled: true }, appliers);
      expect(appliers.setSchedulingEnabled).toHaveBeenCalledWith(true);
    });

    it("calls setSchedulingEnabled(false) when schedulingEnabled is false", () => {
      applySettings({ schedulingEnabled: false }, appliers);
      expect(appliers.setSchedulingEnabled).toHaveBeenCalledWith(false);
    });

    // Absence preserves the in-memory default — the applier must NOT be
    // called, otherwise loading a settings file without the field would
    // overwrite the runtime default with `undefined`.
    it("does not call setSchedulingEnabled when the field is absent", () => {
      applySettings({ maxConcurrent: 4 }, appliers);
      expect(appliers.setSchedulingEnabled).not.toHaveBeenCalled();
    });
  });

  describe("persistToastFor", () => {
    it("returns info-level toast with the plain message on success", () => {
      expect(persistToastFor("Max concurrency set to 7", true)).toEqual({
        message: "Max concurrency set to 7",
        level: "info",
      });
    });

    it("returns warning-level toast with session-only suffix on failure", () => {
      expect(persistToastFor("Max concurrency set to 7", false)).toEqual({
        message: "Max concurrency set to 7 (session only; failed to persist)",
        level: "warning",
      });
    });
  });

  describe("applyAndEmitLoaded", () => {
    let appliers: SettingsAppliers;

    beforeEach(() => {
      appliers = {
        setMaxConcurrent: vi.fn(),
        setDefaultMaxTurns: vi.fn(),
        setGraceTurns: vi.fn(),
        setDefaultJoinMode: vi.fn(),
        setBackgroundByDefault: vi.fn(),
        setSchedulingEnabled: vi.fn(),
        setScopeModels: vi.fn(),
        setStrictAgentFiles: vi.fn(),
        setDisableDefaultAgents: vi.fn(),
        setToolDescriptionMode: vi.fn(),
        setFleetView: vi.fn(),
        setAgentMentions: vi.fn(),
      setRememberAgents: vi.fn(),
        setWidgetMode: vi.fn(),
        setOutputTranscript: vi.fn(),
        setWorktreeIsolation: vi.fn(),
        setMaxSubagentDepth: vi.fn(),
        setFallbackSubagent: vi.fn(),
        setReportUsage: vi.fn(),
        setShowCost: vi.fn(),
      };
    });

    it("loads, applies, and emits subagents:settings_loaded with merged settings", () => {
      writeGlobal({ maxConcurrent: 16 });
      writeProject({ graceTurns: 7 });
      const emit = vi.fn();

      const result = applyAndEmitLoaded(appliers, emit, projectDir);

      expect(appliers.setMaxConcurrent).toHaveBeenCalledWith(16);
      expect(appliers.setGraceTurns).toHaveBeenCalledWith(7);
      expect(appliers.setDefaultMaxTurns).not.toHaveBeenCalled();
      expect(appliers.setDefaultJoinMode).not.toHaveBeenCalled();

      expect(emit).toHaveBeenCalledTimes(1);
      expect(emit).toHaveBeenCalledWith("subagents:settings_loaded", {
        settings: { maxConcurrent: 16, graceTurns: 7 },
      });
      expect(result).toEqual({ maxConcurrent: 16, graceTurns: 7 });
    });

    it("still emits the event when both files are missing (payload carries {})", () => {
      const emit = vi.fn();

      const result = applyAndEmitLoaded(appliers, emit, projectDir);

      expect(emit).toHaveBeenCalledWith("subagents:settings_loaded", { settings: {} });
      expect(result).toEqual({});
      // No setters fired — defaults preserved
      expect(appliers.setMaxConcurrent).not.toHaveBeenCalled();
      expect(appliers.setDefaultMaxTurns).not.toHaveBeenCalled();
      expect(appliers.setGraceTurns).not.toHaveBeenCalled();
      expect(appliers.setDefaultJoinMode).not.toHaveBeenCalled();
    });
  });

  describe("saveAndEmitChanged", () => {
    it("persists, emits with persisted=true, and returns info toast on success", () => {
      const emit = vi.fn();
      const snapshot = { maxConcurrent: 5, graceTurns: 2 };

      const toast = saveAndEmitChanged(snapshot, "Max concurrency set to 5", emit, projectDir);

      expect(emit).toHaveBeenCalledTimes(1);
      expect(emit).toHaveBeenCalledWith("subagents:settings_changed", {
        settings: snapshot,
        persisted: true,
      });
      expect(toast).toEqual({ message: "Max concurrency set to 5", level: "info" });
      // File actually written
      expect(JSON.parse(readFileSync(projectFile(), "utf-8"))).toEqual(snapshot);
    });

    it("emits with persisted=false and returns warning toast on save failure", () => {
      const filePosingAsCwd = join(tmpdir(), `pi-settings-notdir-${Date.now()}`);
      writeFileSync(filePosingAsCwd, "");
      const emit = vi.fn();
      try {
        const toast = saveAndEmitChanged(
          { maxConcurrent: 5 },
          "Max concurrency set to 5",
          emit,
          filePosingAsCwd,
        );
        expect(emit).toHaveBeenCalledWith("subagents:settings_changed", {
          settings: { maxConcurrent: 5 },
          persisted: false,
        });
        expect(toast).toEqual({
          message: "Max concurrency set to 5 (session only; failed to persist)",
          level: "warning",
        });
      } finally {
        rmSync(filePosingAsCwd, { force: true });
      }
    });
  });
});
