/**
 * Tests for ExtensionRunner - conflict detection, error handling, tool wrapping.
 */

import * as fs from "node:fs";
import * as os from "node:os";
import * as path from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { AuthStorage } from "../src/core/auth-storage.js";
import {
  createExtensionRuntime,
  discoverAndLoadExtensions,
} from "../src/core/extensions/loader.js";
import { ExtensionRunner } from "../src/core/extensions/runner.js";
import type {
  ExtensionActions,
  ExtensionContextActions,
  ProviderConfig,
} from "../src/core/extensions/types.js";
import { DEFAULT_KEYBINDINGS, type KeyId } from "../src/core/keybindings.js";
import { ModelRegistry } from "../src/core/model-registry.js";
import { SessionManager } from "../src/core/session-manager.js";

describe("ExtensionRunner", () => {
  let tempDir: string;
  let extensionsDir: string;
  let sessionManager: SessionManager;
  let modelRegistry: ModelRegistry;

  beforeEach(() => {
    tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "pi-runner-test-"));
    extensionsDir = path.join(tempDir, "extensions");
    fs.mkdirSync(extensionsDir);
    sessionManager = SessionManager.inMemory();
    const authStorage = AuthStorage.create(path.join(tempDir, "auth.json"));
    modelRegistry = new ModelRegistry(authStorage);
  });

  afterEach(() => {
    fs.rmSync(tempDir, { recursive: true, force: true });
  });

  const providerModelConfig: ProviderConfig = {
    baseUrl: "https://provider.test/v1",
    apiKey: "PROVIDER_TEST_KEY",
    api: "openai-completions",
    models: [
      {
        id: "instant-model",
        name: "Instant Model",
        reasoning: false,
        input: ["text"],
        cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
        contextWindow: 128000,
        maxTokens: 4096,
      },
    ],
  };

  const extensionActions: ExtensionActions = {
    sendMessage: () => {},
    sendUserMessage: () => {},
    appendEntry: () => {},
    setSessionName: () => {},
    getSessionName: () => undefined,
    setLabel: () => {},
    getActiveTools: () => [],
    getAllTools: () => [],
    setActiveTools: () => {},
    refreshTools: () => {},
    getCommands: () => [],
    setModel: async () => false,
    getThinkingLevel: () => "off",
    setThinkingLevel: () => {},
  };

  const extensionContextActions: ExtensionContextActions = {
    getModel: () => undefined,
    isIdle: () => true,
    abort: () => {},
    hasPendingMessages: () => false,
    shutdown: () => {},
    getContextUsage: () => undefined,
    compact: () => {},
    getSystemPrompt: () => "",
  };

  describe("shortcut conflicts", () => {
    it("warns when extension shortcut conflicts with built-in", async () => {
      const extCode = `
				export default function(pi) {
					pi.registerShortcut("ctrl+c", {
						description: "Conflicts with built-in",
						handler: async () => {},
					});
				}
			`;
      fs.writeFileSync(path.join(extensionsDir, "conflict.ts"), extCode);

      const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});

      const result = await discoverAndLoadExtensions([], tempDir, tempDir);
      const runner = new ExtensionRunner(
        result.extensions,
        result.runtime,
        tempDir,
        sessionManager,
        modelRegistry,
      );
      const shortcuts = runner.getShortcuts(DEFAULT_KEYBINDINGS);

      expect(warnSpy).toHaveBeenCalledWith(
        expect.stringContaining("conflicts with built-in"),
      );
      expect(shortcuts.has("ctrl+c")).toBe(false);

      warnSpy.mockRestore();
    });

    it("allows a shortcut when the reserved set no longer contains the default key", async () => {
      const extCode = `
				export default function(pi) {
					pi.registerShortcut("ctrl+p", {
						description: "Uses freed default",
						handler: async () => {},
					});
				}
			`;
      fs.writeFileSync(path.join(extensionsDir, "rebinding.ts"), extCode);

      const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});

      const result = await discoverAndLoadExtensions([], tempDir, tempDir);
      const runner = new ExtensionRunner(
        result.extensions,
        result.runtime,
        tempDir,
        sessionManager,
        modelRegistry,
      );
      const keybindings = {
        ...DEFAULT_KEYBINDINGS,
        cycleModelForward: "ctrl+n" as KeyId,
      };
      const shortcuts = runner.getShortcuts(keybindings);

      expect(shortcuts.has("ctrl+p")).toBe(true);
      expect(warnSpy).not.toHaveBeenCalledWith(
        expect.stringContaining("conflicts with built-in"),
      );

      warnSpy.mockRestore();
    });

    it("warns but allows when extension uses non-reserved built-in shortcut", async () => {
      const extCode = `
				export default function(pi) {
					pi.registerShortcut("ctrl+v", {
						description: "Overrides non-reserved",
						handler: async () => {},
					});
				}
			`;
      fs.writeFileSync(path.join(extensionsDir, "non-reserved.ts"), extCode);

      const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});

      const result = await discoverAndLoadExtensions([], tempDir, tempDir);
      const runner = new ExtensionRunner(
        result.extensions,
        result.runtime,
        tempDir,
        sessionManager,
        modelRegistry,
      );
      const shortcuts = runner.getShortcuts(DEFAULT_KEYBINDINGS);

      expect(warnSpy).toHaveBeenCalledWith(
        expect.stringContaining("built-in shortcut for pasteImage"),
      );
      expect(shortcuts.has("ctrl+v")).toBe(true);

      warnSpy.mockRestore();
    });

    it("blocks shortcuts for reserved actions even when rebound", async () => {
      const extCode = `
				export default function(pi) {
					pi.registerShortcut("ctrl+x", {
						description: "Conflicts with rebound reserved",
						handler: async () => {},
					});
				}
			`;
      fs.writeFileSync(
        path.join(extensionsDir, "rebound-reserved.ts"),
        extCode,
      );

      const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});

      const result = await discoverAndLoadExtensions([], tempDir, tempDir);
      const runner = new ExtensionRunner(
        result.extensions,
        result.runtime,
        tempDir,
        sessionManager,
        modelRegistry,
      );
      const keybindings = {
        ...DEFAULT_KEYBINDINGS,
        interrupt: "ctrl+x" as KeyId,
      };
      const shortcuts = runner.getShortcuts(keybindings);

      expect(warnSpy).toHaveBeenCalledWith(
        expect.stringContaining("conflicts with built-in"),
      );
      expect(shortcuts.has("ctrl+x")).toBe(false);

      warnSpy.mockRestore();
    });

    it("blocks shortcuts when reserved action has multiple keys", async () => {
      const extCode = `
				export default function(pi) {
					pi.registerShortcut("ctrl+y", {
						description: "Conflicts with multi-key reserved",
						handler: async () => {},
					});
				}
			`;
      fs.writeFileSync(path.join(extensionsDir, "multi-reserved.ts"), extCode);

      const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});

      const result = await discoverAndLoadExtensions([], tempDir, tempDir);
      const runner = new ExtensionRunner(
        result.extensions,
        result.runtime,
        tempDir,
        sessionManager,
        modelRegistry,
      );
      const keybindings = {
        ...DEFAULT_KEYBINDINGS,
        clear: ["ctrl+x", "ctrl+y"] as KeyId[],
      };
      const shortcuts = runner.getShortcuts(keybindings);

      expect(warnSpy).toHaveBeenCalledWith(
        expect.stringContaining("conflicts with built-in"),
      );
      expect(shortcuts.has("ctrl+y")).toBe(false);

      warnSpy.mockRestore();
    });

    it("warns but allows when non-reserved action has multiple keys", async () => {
      const extCode = `
				export default function(pi) {
					pi.registerShortcut("ctrl+y", {
						description: "Overrides multi-key non-reserved",
						handler: async () => {},
					});
				}
			`;
      fs.writeFileSync(
        path.join(extensionsDir, "multi-non-reserved.ts"),
        extCode,
      );

      const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});

      const result = await discoverAndLoadExtensions([], tempDir, tempDir);
      const runner = new ExtensionRunner(
        result.extensions,
        result.runtime,
        tempDir,
        sessionManager,
        modelRegistry,
      );
      const keybindings = {
        ...DEFAULT_KEYBINDINGS,
        pasteImage: ["ctrl+x", "ctrl+y"] as KeyId[],
      };
      const shortcuts = runner.getShortcuts(keybindings);

      expect(warnSpy).toHaveBeenCalledWith(
        expect.stringContaining("built-in shortcut for pasteImage"),
      );
      expect(shortcuts.has("ctrl+y")).toBe(true);

      warnSpy.mockRestore();
    });

    it("warns when two extensions register same shortcut", async () => {
      // Use a non-reserved shortcut
      const extCode1 = `
				export default function(pi) {
					pi.registerShortcut("ctrl+shift+x", {
						description: "First extension",
						handler: async () => {},
					});
				}
			`;
      const extCode2 = `
				export default function(pi) {
					pi.registerShortcut("ctrl+shift+x", {
						description: "Second extension",
						handler: async () => {},
					});
				}
			`;
      fs.writeFileSync(path.join(extensionsDir, "ext1.ts"), extCode1);
      fs.writeFileSync(path.join(extensionsDir, "ext2.ts"), extCode2);

      const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});

      const result = await discoverAndLoadExtensions([], tempDir, tempDir);
      const runner = new ExtensionRunner(
        result.extensions,
        result.runtime,
        tempDir,
        sessionManager,
        modelRegistry,
      );
      const shortcuts = runner.getShortcuts(DEFAULT_KEYBINDINGS);

      expect(warnSpy).toHaveBeenCalledWith(
        expect.stringContaining("shortcut conflict"),
      );
      // Last one wins
      expect(shortcuts.has("ctrl+shift+x")).toBe(true);

      warnSpy.mockRestore();
    });
  });

  describe("tool collection", () => {
    it("collects tools from multiple extensions", async () => {
      const toolCode = (name: string) => `
				import { Type } from "@sinclair/typebox";
				export default function(pi) {
					pi.registerTool({
						name: "${name}",
						label: "${name}",
						description: "Test tool",
						parameters: Type.Object({}),
						execute: async () => ({ content: [{ type: "text", text: "ok" }], details: {} }),
					});
				}
			`;
      fs.writeFileSync(
        path.join(extensionsDir, "tool-a.ts"),
        toolCode("tool_a"),
      );
      fs.writeFileSync(
        path.join(extensionsDir, "tool-b.ts"),
        toolCode("tool_b"),
      );

      const result = await discoverAndLoadExtensions([], tempDir, tempDir);
      const runner = new ExtensionRunner(
        result.extensions,
        result.runtime,
        tempDir,
        sessionManager,
        modelRegistry,
      );
      const tools = runner.getAllRegisteredTools();

      expect(tools.length).toBe(2);
      expect(tools.map((t) => t.definition.name).sort()).toEqual([
        "tool_a",
        "tool_b",
      ]);
    });

    it("keeps first tool when two extensions register the same name", async () => {
      const first = `
				import { Type } from "@sinclair/typebox";
				export default function(pi) {
					pi.registerTool({
						name: "shared",
						label: "shared",
						description: "first",
						parameters: Type.Object({}),
						execute: async () => ({ content: [{ type: "text", text: "ok" }], details: {} }),
					});
				}
			`;
      const second = `
				import { Type } from "@sinclair/typebox";
				export default function(pi) {
					pi.registerTool({
						name: "shared",
						label: "shared",
						description: "second",
						parameters: Type.Object({}),
						execute: async () => ({ content: [{ type: "text", text: "ok" }], details: {} }),
					});
				}
			`;
      fs.writeFileSync(path.join(extensionsDir, "a-first.ts"), first);
      fs.writeFileSync(path.join(extensionsDir, "b-second.ts"), second);

      const result = await discoverAndLoadExtensions([], tempDir, tempDir);
      const runner = new ExtensionRunner(
        result.extensions,
        result.runtime,
        tempDir,
        sessionManager,
        modelRegistry,
      );
      const tools = runner.getAllRegisteredTools();

      expect(tools).toHaveLength(1);
      expect(tools[0]?.definition.description).toBe("first");
    });
  });

  describe("command collection", () => {
    it("collects commands from multiple extensions", async () => {
      const cmdCode = (name: string) => `
				export default function(pi) {
					pi.registerCommand("${name}", {
						description: "Test command",
						handler: async () => {},
					});
				}
			`;
      fs.writeFileSync(path.join(extensionsDir, "cmd-a.ts"), cmdCode("cmd-a"));
      fs.writeFileSync(path.join(extensionsDir, "cmd-b.ts"), cmdCode("cmd-b"));

      const result = await discoverAndLoadExtensions([], tempDir, tempDir);
      const runner = new ExtensionRunner(
        result.extensions,
        result.runtime,
        tempDir,
        sessionManager,
        modelRegistry,
      );
      const commands = runner.getRegisteredCommands();

      expect(commands.length).toBe(2);
      expect(commands.map((c) => c.name).sort()).toEqual(["cmd-a", "cmd-b"]);
    });

    it("gets command by name", async () => {
      const cmdCode = `
				export default function(pi) {
					pi.registerCommand("my-cmd", {
						description: "My command",
						handler: async () => {},
					});
				}
			`;
      fs.writeFileSync(path.join(extensionsDir, "cmd.ts"), cmdCode);

      const result = await discoverAndLoadExtensions([], tempDir, tempDir);
      const runner = new ExtensionRunner(
        result.extensions,
        result.runtime,
        tempDir,
        sessionManager,
        modelRegistry,
      );

      const cmd = runner.getCommand("my-cmd");
      expect(cmd).toBeDefined();
      expect(cmd?.name).toBe("my-cmd");
      expect(cmd?.description).toBe("My command");

      const missing = runner.getCommand("not-exists");
      expect(missing).toBeUndefined();
    });

    it("filters out commands conflict with reseved", async () => {
      const cmdCode = (name: string) => `
				export default function(pi) {
					pi.registerCommand("${name}", {
						description: "Test command",
						handler: async () => {},
					});
				}
			`;
      fs.writeFileSync(path.join(extensionsDir, "cmd-a.ts"), cmdCode("cmd-a"));
      fs.writeFileSync(path.join(extensionsDir, "cmd-b.ts"), cmdCode("cmd-b"));

      const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});

      const result = await discoverAndLoadExtensions([], tempDir, tempDir);
      const runner = new ExtensionRunner(
        result.extensions,
        result.runtime,
        tempDir,
        sessionManager,
        modelRegistry,
      );
      const commands = runner.getRegisteredCommands(new Set(["cmd-a"]));
      const diagnostics = runner.getCommandDiagnostics();

      expect(commands.length).toBe(1);
      expect(commands.map((c) => c.name).sort()).toEqual(["cmd-b"]);

      expect(diagnostics.length).toBe(1);
      expect(diagnostics[0].path).toEqual(path.join(extensionsDir, "cmd-a.ts"));

      expect(warnSpy).toHaveBeenCalledWith(
        expect.stringContaining("conflicts with built-in command"),
      );
      warnSpy.mockRestore();
    });
  });

  describe("error handling", () => {
    it("calls error listeners when handler throws", async () => {
      const extCode = `
				export default function(pi) {
					pi.on("context", async () => {
						throw new Error("Handler error!");
					});
				}
			`;
      fs.writeFileSync(path.join(extensionsDir, "throws.ts"), extCode);

      const result = await discoverAndLoadExtensions([], tempDir, tempDir);
      const runner = new ExtensionRunner(
        result.extensions,
        result.runtime,
        tempDir,
        sessionManager,
        modelRegistry,
      );

      const errors: Array<{
        extensionPath: string;
        event: string;
        error: string;
      }> = [];
      runner.onError((err) => {
        errors.push(err);
      });

      // Emit context event which will trigger the throwing handler
      await runner.emitContext([]);

      expect(errors.length).toBe(1);
      expect(errors[0].error).toContain("Handler error!");
      expect(errors[0].event).toBe("context");
    });
  });

  describe("message renderers", () => {
    it("gets message renderer by type", async () => {
      const extCode = `
				export default function(pi) {
					pi.registerMessageRenderer("my-type", (message, options, theme) => null);
				}
			`;
      fs.writeFileSync(path.join(extensionsDir, "renderer.ts"), extCode);

      const result = await discoverAndLoadExtensions([], tempDir, tempDir);
      const runner = new ExtensionRunner(
        result.extensions,
        result.runtime,
        tempDir,
        sessionManager,
        modelRegistry,
      );

      const renderer = runner.getMessageRenderer("my-type");
      expect(renderer).toBeDefined();

      const missing = runner.getMessageRenderer("not-exists");
      expect(missing).toBeUndefined();
    });
  });

  describe("flags", () => {
    it("collects flags from extensions", async () => {
      const extCode = `
				export default function(pi) {
					pi.registerFlag("my-flag", {
						description: "My flag",
						handler: async () => {},
					});
				}
			`;
      fs.writeFileSync(path.join(extensionsDir, "with-flag.ts"), extCode);

      const result = await discoverAndLoadExtensions([], tempDir, tempDir);
      const runner = new ExtensionRunner(
        result.extensions,
        result.runtime,
        tempDir,
        sessionManager,
        modelRegistry,
      );
      const flags = runner.getFlags();

      expect(flags.has("my-flag")).toBe(true);
    });

    it("keeps first flag when two extensions register the same name", async () => {
      const first = `
				export default function(pi) {
					pi.registerFlag("shared-flag", {
						description: "first",
						type: "boolean",
						default: true,
					});
				}
			`;
      const second = `
				export default function(pi) {
					pi.registerFlag("shared-flag", {
						description: "second",
						type: "boolean",
						default: false,
					});
				}
			`;
      fs.writeFileSync(path.join(extensionsDir, "a-first.ts"), first);
      fs.writeFileSync(path.join(extensionsDir, "b-second.ts"), second);

      const result = await discoverAndLoadExtensions([], tempDir, tempDir);
      const runner = new ExtensionRunner(
        result.extensions,
        result.runtime,
        tempDir,
        sessionManager,
        modelRegistry,
      );
      const flags = runner.getFlags();

      expect(flags.get("shared-flag")?.description).toBe("first");
      expect(result.runtime.flagValues.get("shared-flag")).toBe(true);
    });

    it("can set flag values", async () => {
      const extCode = `
				export default function(pi) {
					pi.registerFlag("test-flag", {
						description: "Test flag",
						handler: async () => {},
					});
				}
			`;
      fs.writeFileSync(path.join(extensionsDir, "flag.ts"), extCode);

      const result = await discoverAndLoadExtensions([], tempDir, tempDir);
      const runner = new ExtensionRunner(
        result.extensions,
        result.runtime,
        tempDir,
        sessionManager,
        modelRegistry,
      );

      // Setting a flag value should not throw
      runner.setFlagValue("--test-flag", true);

      // The flag values are stored in the shared runtime
      expect(result.runtime.flagValues.get("--test-flag")).toBe(true);
    });
  });

  describe("tool_result chaining", () => {
    it("chains content modifications across handlers", async () => {
      const extCode1 = `
				export default function(pi) {
					pi.on("tool_result", async (event) => {
						return {
							content: [...event.content, { type: "text", text: "ext1" }],
						};
					});
				}
			`;
      const extCode2 = `
				export default function(pi) {
					pi.on("tool_result", async (event) => {
						return {
							content: [...event.content, { type: "text", text: "ext2" }],
						};
					});
				}
			`;
      fs.writeFileSync(path.join(extensionsDir, "tool-result-1.ts"), extCode1);
      fs.writeFileSync(path.join(extensionsDir, "tool-result-2.ts"), extCode2);

      const result = await discoverAndLoadExtensions([], tempDir, tempDir);
      const runner = new ExtensionRunner(
        result.extensions,
        result.runtime,
        tempDir,
        sessionManager,
        modelRegistry,
      );

      const chained = await runner.emitToolResult({
        type: "tool_result",
        toolName: "my_tool",
        toolCallId: "call-1",
        input: {},
        content: [{ type: "text", text: "base" }],
        details: { initial: true },
        isError: false,
      });

      expect(chained).toBeDefined();
      const chainedContent = chained?.content;
      expect(chainedContent).toBeDefined();
      expect(chainedContent![0]).toEqual({ type: "text", text: "base" });
      expect(chainedContent).toHaveLength(3);
      const appendedText = chainedContent!
        .slice(1)
        .filter(
          (item): item is { type: "text"; text: string } =>
            item.type === "text",
        )
        .map((item) => item.text);
      expect(appendedText.sort()).toEqual(["ext1", "ext2"]);
    });

    it("preserves previous modifications when later handlers return partial patches", async () => {
      const extCode1 = `
				export default function(pi) {
					pi.on("tool_result", async () => {
						return {
							content: [{ type: "text", text: "first" }],
							details: { source: "ext1" },
						};
					});
				}
			`;
      const extCode2 = `
				export default function(pi) {
					pi.on("tool_result", async () => {
						return {
							isError: true,
						};
					});
				}
			`;
      fs.writeFileSync(
        path.join(extensionsDir, "tool-result-partial-1.ts"),
        extCode1,
      );
      fs.writeFileSync(
        path.join(extensionsDir, "tool-result-partial-2.ts"),
        extCode2,
      );

      const result = await discoverAndLoadExtensions([], tempDir, tempDir);
      const runner = new ExtensionRunner(
        result.extensions,
        result.runtime,
        tempDir,
        sessionManager,
        modelRegistry,
      );

      const chained = await runner.emitToolResult({
        type: "tool_result",
        toolName: "my_tool",
        toolCallId: "call-2",
        input: {},
        content: [{ type: "text", text: "base" }],
        details: { initial: true },
        isError: false,
      });

      expect(chained).toEqual({
        content: [{ type: "text", text: "first" }],
        details: { source: "ext1" },
        isError: true,
      });
    });
  });

  describe("provider registration", () => {
    it("pre-bind unregister removes all queued registrations for a provider", () => {
      const runtime = createExtensionRuntime();

      runtime.registerProvider("queued-provider", providerModelConfig);
      runtime.registerProvider("queued-provider", {
        ...providerModelConfig,
        models: [
          {
            id: "instant-model-2",
            name: "Instant Model 2",
            reasoning: false,
            input: ["text"],
            cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
            contextWindow: 128000,
            maxTokens: 4096,
          },
        ],
      });
      expect(runtime.pendingProviderRegistrations).toHaveLength(2);

      runtime.unregisterProvider("queued-provider");
      expect(runtime.pendingProviderRegistrations).toHaveLength(0);
    });

    it("post-bind register and unregister take effect immediately", () => {
      const runtime = createExtensionRuntime();
      const runner = new ExtensionRunner(
        [],
        runtime,
        tempDir,
        sessionManager,
        modelRegistry,
      );

      runner.bindCore(extensionActions, extensionContextActions);
      expect(runtime.pendingProviderRegistrations).toHaveLength(0);

      runtime.registerProvider("instant-provider", providerModelConfig);
      expect(runtime.pendingProviderRegistrations).toHaveLength(0);
      expect(
        modelRegistry.find("instant-provider", "instant-model"),
      ).toBeDefined();

      runtime.unregisterProvider("instant-provider");
      expect(
        modelRegistry.find("instant-provider", "instant-model"),
      ).toBeUndefined();
    });
  });

  describe("hasHandlers", () => {
    it("returns true when handlers exist for event type", async () => {
      const extCode = `
				export default function(pi) {
					pi.on("tool_call", async () => undefined);
				}
			`;
      fs.writeFileSync(path.join(extensionsDir, "handler.ts"), extCode);

      const result = await discoverAndLoadExtensions([], tempDir, tempDir);
      const runner = new ExtensionRunner(
        result.extensions,
        result.runtime,
        tempDir,
        sessionManager,
        modelRegistry,
      );

      expect(runner.hasHandlers("tool_call")).toBe(true);
      expect(runner.hasHandlers("agent_end")).toBe(false);
    });
  });
});
