import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import type { AgentConfig } from "./agent-types.js";
import type { SdkSessionInfo } from "./cli-launcher.js";

// ─── Hoisted mocks ──────────────────────────────────────────────────────────

// Mock logger — prevents pino initialization issues in tests
vi.mock("./logger.js", () => {
  const noop = () => {};
  const noopLogger = {
    info: noop,
    warn: noop,
    error: noop,
    debug: noop,
    trace: noop,
    fatal: noop,
    child: () => noopLogger,
  };
  return {
    logger: noopLogger,
    createLogger: () => noopLogger,
    createSessionLogger: () => noopLogger,
  };
});

// Mock agent-store — controls agent data without filesystem access
const mockAgents = vi.hoisted(() => new Map<string, AgentConfig>());
vi.mock("./agent-store.js", () => ({
  listAgents: () => Array.from(mockAgents.values()),
  getAgent: (id: string) => mockAgents.get(id) || null,
  updateAgent: vi.fn((id: string, updates: Partial<AgentConfig>) => {
    const existing = mockAgents.get(id);
    if (existing) {
      mockAgents.set(id, { ...existing, ...updates } as AgentConfig);
    }
  }),
}));

// Mock env-manager
vi.mock("./env-manager.js", () => ({
  getEnv: vi.fn(() => null),
}));

// Mock session-names
vi.mock("./session-names.js", () => ({
  setName: vi.fn(),
}));

// ─── Import under test ──────────────────────────────────────────────────────

import { AgentExecutor } from "./agent-executor.js";

// ─── Helpers ─────────────────────────────────────────────────────────────────

/** Creates a minimal AgentConfig for testing */
function makeAgent(overrides: Partial<AgentConfig> = {}): AgentConfig {
  return {
    id: "test-agent",
    version: 1,
    name: "Test Agent",
    description: "A test agent",
    backendType: "claude",
    model: "claude-sonnet-4-6",
    permissionMode: "bypassPermissions",
    cwd: "/tmp/test-workspace",
    prompt: "Do something {{input}}",
    enabled: true,
    createdAt: Date.now(),
    updatedAt: Date.now(),
    totalRuns: 0,
    consecutiveFailures: 0,
    ...overrides,
  };
}

/** Creates a mock SdkSessionInfo */
function makeFakeSession(overrides: Partial<SdkSessionInfo> = {}): SdkSessionInfo {
  return {
    sessionId: "sess-agent-001",
    state: "connected",
    cwd: "/tmp/test-workspace",
    createdAt: Date.now(),
    ...overrides,
  };
}

/** Creates a mock CliLauncher */
function createMockLauncher() {
  return {
    launch: vi.fn(async () => makeFakeSession()),
    isAlive: vi.fn(() => false),
    getSession: vi.fn(
      (): SdkSessionInfo => ({
        sessionId: "sess-agent-001",
        state: "connected",
        cwd: "/tmp/test-workspace",
        createdAt: Date.now(),
      }),
    ),
  };
}

/** Creates a mock WsBridge */
function createMockWsBridge() {
  return {
    injectMcpSetServers: vi.fn(),
    injectUserMessage: vi.fn(),
  };
}

// =============================================================================
// Tests
// =============================================================================

describe("AgentExecutor", () => {
  let executor: AgentExecutor;
  let mockLauncher: ReturnType<typeof createMockLauncher>;
  let mockWsBridge: ReturnType<typeof createMockWsBridge>;

  beforeEach(() => {
    vi.clearAllMocks();
    mockAgents.clear();
    mockLauncher = createMockLauncher();
    mockWsBridge = createMockWsBridge();
    executor = new AgentExecutor(mockLauncher as any, mockWsBridge as any);
  });

  afterEach(() => {
    executor.destroy();
  });

  // ─── startAll / stopAll ─────────────────────────────────────────────────

  describe("startAll", () => {
    it("schedules enabled agents that have schedule triggers", () => {
      const agent = makeAgent({
        id: "scheduled-agent",
        triggers: {
          schedule: { enabled: true, expression: "*/5 * * * *", recurring: true },
        },
      });
      mockAgents.set(agent.id, agent);

      executor.startAll();

      // Agent should be scheduled — getNextRunTime returns a Date
      const nextRun = executor.getNextRunTime("scheduled-agent");
      expect(nextRun).toBeInstanceOf(Date);
    });

    it("skips disabled agents", () => {
      const agent = makeAgent({
        id: "disabled-agent",
        enabled: false,
        triggers: {
          schedule: { enabled: true, expression: "*/5 * * * *", recurring: true },
        },
      });
      mockAgents.set(agent.id, agent);

      executor.startAll();

      expect(executor.getNextRunTime("disabled-agent")).toBeNull();
    });

    it("skips agents without schedule triggers", () => {
      const agent = makeAgent({ id: "no-schedule-agent" });
      mockAgents.set(agent.id, agent);

      executor.startAll();

      expect(executor.getNextRunTime("no-schedule-agent")).toBeNull();
    });
  });

  // ─── stopAgent ──────────────────────────────────────────────────────────

  describe("stopAgent", () => {
    it("stops a scheduled agent timer", () => {
      const agent = makeAgent({
        id: "agent-to-stop",
        triggers: {
          schedule: { enabled: true, expression: "*/5 * * * *", recurring: true },
        },
      });
      mockAgents.set(agent.id, agent);
      executor.scheduleAgent(agent);
      expect(executor.getNextRunTime("agent-to-stop")).not.toBeNull();

      executor.stopAgent("agent-to-stop");

      expect(executor.getNextRunTime("agent-to-stop")).toBeNull();
    });

    it("is a no-op for non-scheduled agents", () => {
      // Should not throw
      executor.stopAgent("nonexistent");
    });
  });

  // ─── executeAgent ─────────────────────────────────────────────────────

  describe("executeAgent", () => {
    it("launches a session, waits for connection, and sends the prompt", async () => {
      const agent = makeAgent({
        id: "exec-agent",
        prompt: "Hello from agent",
      });
      mockAgents.set(agent.id, agent);

      const result = await executor.executeAgent("exec-agent");

      expect(mockLauncher.launch).toHaveBeenCalledTimes(1);
      expect(mockWsBridge.injectUserMessage).toHaveBeenCalledWith(
        "sess-agent-001",
        expect.stringContaining("Hello from agent"),
      );
      expect(result).toBeDefined();
      expect(result!.sessionId).toBe("sess-agent-001");
    });

    it("replaces {{input}} placeholder in the prompt", async () => {
      const agent = makeAgent({
        id: "input-agent",
        prompt: "Process: {{input}}",
      });
      mockAgents.set(agent.id, agent);

      await executor.executeAgent("input-agent", "my-data");

      expect(mockWsBridge.injectUserMessage).toHaveBeenCalledWith(
        "sess-agent-001",
        expect.stringContaining("Process: my-data"),
      );
    });

    it("clears {{input}} when no input is provided", async () => {
      const agent = makeAgent({
        id: "no-input-agent",
        prompt: "Run {{input}} task",
      });
      mockAgents.set(agent.id, agent);

      await executor.executeAgent("no-input-agent");

      expect(mockWsBridge.injectUserMessage).toHaveBeenCalledWith(
        "sess-agent-001",
        expect.stringContaining("Run  task"),
      );
    });

    it("returns undefined for non-existent agent", async () => {
      const result = await executor.executeAgent("ghost");
      expect(result).toBeUndefined();
    });

    it("returns undefined for disabled agent (without force)", async () => {
      const agent = makeAgent({ id: "disabled-exec", enabled: false });
      mockAgents.set(agent.id, agent);

      const result = await executor.executeAgent("disabled-exec");
      expect(result).toBeUndefined();
      expect(mockLauncher.launch).not.toHaveBeenCalled();
    });

    it("executes disabled agent when force=true", async () => {
      const agent = makeAgent({ id: "forced-agent", enabled: false });
      mockAgents.set(agent.id, agent);

      const result = await executor.executeAgent("forced-agent", undefined, { force: true });
      expect(result).toBeDefined();
      expect(mockLauncher.launch).toHaveBeenCalled();
    });

    it("skips execution if previous session is still alive (overlap prevention)", async () => {
      const agent = makeAgent({
        id: "overlap-agent",
        lastSessionId: "old-session",
      });
      mockAgents.set(agent.id, agent);
      mockLauncher.isAlive.mockReturnValue(true);

      const result = await executor.executeAgent("overlap-agent");
      expect(result).toBeUndefined();
      expect(mockLauncher.launch).not.toHaveBeenCalled();
    });

    it("injects MCP servers when configured", async () => {
      const agent = makeAgent({
        id: "mcp-agent",
        mcpServers: {
          myServer: { type: "stdio", command: "node", args: ["server.js"] },
        },
      });
      mockAgents.set(agent.id, agent);

      await executor.executeAgent("mcp-agent");

      expect(mockWsBridge.injectMcpSetServers).toHaveBeenCalledWith(
        "sess-agent-001",
        agent.mcpServers,
      );
    }, 10_000); // Allow extra time for the 2s MCP initialization delay
  });

  // ─── Error handling ───────────────────────────────────────────────────

  describe("error handling", () => {
    it("increments consecutiveFailures when launcher fails", async () => {
      const agent = makeAgent({ id: "fail-agent", consecutiveFailures: 0 });
      mockAgents.set(agent.id, agent);
      mockLauncher.launch.mockRejectedValueOnce(new Error("CLI crashed"));

      const result = await executor.executeAgent("fail-agent");

      expect(result).toBeUndefined();
      const updated = mockAgents.get("fail-agent");
      expect(updated?.consecutiveFailures).toBe(1);
    });

    it("auto-disables agent after MAX_CONSECUTIVE_FAILURES (5)", async () => {
      const agent = makeAgent({
        id: "fragile-agent",
        consecutiveFailures: 4,
        enabled: true,
      });
      mockAgents.set(agent.id, agent);
      mockLauncher.launch.mockRejectedValueOnce(new Error("fail again"));

      await executor.executeAgent("fragile-agent");

      const updated = mockAgents.get("fragile-agent");
      expect(updated?.enabled).toBe(false);
      expect(updated?.consecutiveFailures).toBe(5);
    });

    it("records the error in execution history", async () => {
      const agent = makeAgent({ id: "error-track-agent" });
      mockAgents.set(agent.id, agent);
      mockLauncher.launch.mockRejectedValueOnce(new Error("Boom!"));

      await executor.executeAgent("error-track-agent");

      const executions = executor.getExecutions("error-track-agent");
      expect(executions.length).toBe(1);
      expect(executions[0].error).toBe("Boom!");
      expect(executions[0].success).toBeUndefined();
    });
  });

  // ─── Execution history ────────────────────────────────────────────────

  describe("execution history", () => {
    it("tracks successful executions", async () => {
      const agent = makeAgent({ id: "track-agent" });
      mockAgents.set(agent.id, agent);

      await executor.executeAgent("track-agent");

      const executions = executor.getExecutions("track-agent");
      expect(executions.length).toBe(1);
      expect(executions[0].success).toBe(true);
      expect(executions[0].sessionId).toBe("sess-agent-001");
      expect(executions[0].agentId).toBe("track-agent");
    });

    it("returns empty array for unknown agent", () => {
      expect(executor.getExecutions("nobody")).toEqual([]);
    });

    it("caps execution history at 50 entries per agent", async () => {
      const agent = makeAgent({ id: "many-runs-agent" });
      mockAgents.set(agent.id, agent);

      // Execute 55 times
      for (let i = 0; i < 55; i++) {
        // Reset totalRuns each time so agent-store updateAgent doesn't interfere
        mockAgents.get("many-runs-agent")!.totalRuns = i;
        await executor.executeAgent("many-runs-agent");
      }

      const executions = executor.getExecutions("many-runs-agent");
      expect(executions.length).toBe(50);
    });
  });

  // ─── destroy ──────────────────────────────────────────────────────────

  describe("destroy", () => {
    it("clears all timers and execution history", () => {
      const agent = makeAgent({
        id: "destroyable",
        triggers: {
          schedule: { enabled: true, expression: "*/5 * * * *", recurring: true },
        },
      });
      mockAgents.set(agent.id, agent);
      executor.scheduleAgent(agent);

      executor.destroy();

      expect(executor.getNextRunTime("destroyable")).toBeNull();
      expect(executor.getExecutions("destroyable")).toEqual([]);
    });
  });

  // ─── getNextRunTime ───────────────────────────────────────────────────

  describe("getNextRunTime", () => {
    it("returns null for non-scheduled agent", () => {
      expect(executor.getNextRunTime("nonexistent")).toBeNull();
    });

    it("returns a future Date for scheduled agent", () => {
      const agent = makeAgent({
        id: "future-agent",
        triggers: {
          schedule: { enabled: true, expression: "*/5 * * * *", recurring: true },
        },
      });
      mockAgents.set(agent.id, agent);
      executor.scheduleAgent(agent);

      const next = executor.getNextRunTime("future-agent");
      expect(next).toBeInstanceOf(Date);
    });
  });

  // ─── executeAgentManually ─────────────────────────────────────────────

  describe("executeAgentManually", () => {
    it("fires and forgets — does not return a promise that blocks", () => {
      const agent = makeAgent({ id: "manual-agent" });
      mockAgents.set(agent.id, agent);

      // executeAgentManually returns void, not a promise
      const result = executor.executeAgentManually("manual-agent", "test-input");
      expect(result).toBeUndefined();
    });

    it("forces execution even for disabled agents", async () => {
      const agent = makeAgent({ id: "disabled-manual", enabled: false });
      mockAgents.set(agent.id, agent);

      executor.executeAgentManually("disabled-manual");

      // Wait for the fire-and-forget to resolve
      await new Promise((r) => setTimeout(r, 100));

      expect(mockLauncher.launch).toHaveBeenCalled();
    });

    it("replaces {{input}} in the prompt when input is provided", async () => {
      const agent = makeAgent({
        id: "manual-input-agent",
        prompt: "Process: {{input}}",
      });
      mockAgents.set(agent.id, agent);

      executor.executeAgentManually("manual-input-agent", "my-data");

      await new Promise((r) => setTimeout(r, 100));

      expect(mockWsBridge.injectUserMessage).toHaveBeenCalledWith(
        "sess-agent-001",
        expect.stringContaining("Process: my-data"),
      );
    });
  });

  // ─── CLI exit before WebSocket connection ─────────────────────────────

  describe("CLI exit before WS connection", () => {
    it("fails execution when CLI exits before connecting", async () => {
      const agent = makeAgent({ id: "early-exit-agent" });
      mockAgents.set(agent.id, agent);

      // Simulate CLI process that exits immediately
      mockLauncher.getSession.mockReturnValue({
        sessionId: "sess-agent-001",
        state: "exited",
        exitCode: 1,
        cwd: "/tmp/test-workspace",
        createdAt: Date.now(),
      } as any);

      const result = await executor.executeAgent("early-exit-agent");

      expect(result).toBeUndefined();
      const execs = executor.getExecutions("early-exit-agent");
      expect(execs.length).toBe(1);
      expect(execs[0].error).toContain("exited before connecting");
    });
  });

  // ─── Environment variable merging ─────────────────────────────────────

  describe("environment variable merging", () => {
    it("merges envSlug variables with agent.env overrides", async () => {
      const { getEnv } = await import("./env-manager.js");
      (getEnv as any).mockReturnValueOnce({
        name: "staging",
        variables: { API_URL: "https://staging.example.com", DB_HOST: "db-staging" },
      });

      const agent = makeAgent({
        id: "env-merge-agent",
        envSlug: "staging",
        env: { DB_HOST: "db-override", EXTRA_VAR: "extra" },
      });
      mockAgents.set(agent.id, agent);

      await executor.executeAgent("env-merge-agent");

      const launchArgs = (mockLauncher.launch as any).mock.calls[0][0];
      // agent.env overrides envSlug vars for the same key
      expect(launchArgs.env).toEqual({
        API_URL: "https://staging.example.com",
        DB_HOST: "db-override",
        EXTRA_VAR: "extra",
      });
    });

    it("uses only agent.env when envSlug is not set", async () => {
      const agent = makeAgent({
        id: "env-only-agent",
        env: { MY_VAR: "value" },
      });
      mockAgents.set(agent.id, agent);

      await executor.executeAgent("env-only-agent");

      const launchArgs = (mockLauncher.launch as any).mock.calls[0][0];
      expect(launchArgs.env).toEqual({ MY_VAR: "value" });
    });
  });

  // ─── Temp working directory ───────────────────────────────────────────

  describe("temp working directory", () => {
    it("creates a temp directory when cwd is 'temp'", async () => {
      const agent = makeAgent({ id: "temp-cwd-agent", cwd: "temp" });
      mockAgents.set(agent.id, agent);

      await executor.executeAgent("temp-cwd-agent");

      const launchArgs = (mockLauncher.launch as any).mock.calls[0][0];
      // Temp dir should start with the OS temp directory prefix
      expect(launchArgs.cwd).toContain("zencode-agent-temp-cwd-agent-");
    });

    it("creates a temp directory when cwd is empty/undefined", async () => {
      const agent = makeAgent({ id: "no-cwd-agent", cwd: undefined as any });
      mockAgents.set(agent.id, agent);

      await executor.executeAgent("no-cwd-agent");

      const launchArgs = (mockLauncher.launch as any).mock.calls[0][0];
      expect(launchArgs.cwd).toContain("zencode-agent-no-cwd-agent-");
    });
  });

  // ─── One-shot scheduling with past dates ──────────────────────────────

  describe("one-shot scheduling", () => {
    it("skips scheduling when target time is in the past", () => {
      const pastDate = new Date(Date.now() - 60_000).toISOString();
      const agent = makeAgent({
        id: "past-agent",
        triggers: {
          schedule: { enabled: true, expression: pastDate, recurring: false },
        },
      });
      mockAgents.set(agent.id, agent);

      executor.scheduleAgent(agent);

      // Should not be scheduled since the time is in the past
      expect(executor.getNextRunTime("past-agent")).toBeNull();
    });

    it("schedules when target time is in the future", () => {
      const futureDate = new Date(Date.now() + 60_000).toISOString();
      const agent = makeAgent({
        id: "future-one-shot",
        triggers: {
          schedule: { enabled: true, expression: futureDate, recurring: false },
        },
      });
      mockAgents.set(agent.id, agent);

      executor.scheduleAgent(agent);

      const next = executor.getNextRunTime("future-one-shot");
      expect(next).toBeInstanceOf(Date);
    });
  });

  // ─── Trigger type tracking ────────────────────────────────────────────

  describe("trigger type tracking", () => {
    it("records 'manual' as default trigger type", async () => {
      const agent = makeAgent({ id: "trigger-default-agent" });
      mockAgents.set(agent.id, agent);

      await executor.executeAgent("trigger-default-agent");

      const execs = executor.getExecutions("trigger-default-agent");
      expect(execs[0].triggerType).toBe("manual");
    });

    it("records custom trigger type when provided", async () => {
      const agent = makeAgent({ id: "trigger-webhook-agent" });
      mockAgents.set(agent.id, agent);

      await executor.executeAgent("trigger-webhook-agent", undefined, {
        triggerType: "webhook",
      });

      const execs = executor.getExecutions("trigger-webhook-agent");
      expect(execs[0].triggerType).toBe("webhook");
    });

    it("records 'schedule' trigger type for scheduled executions", async () => {
      const agent = makeAgent({ id: "trigger-schedule-agent" });
      mockAgents.set(agent.id, agent);

      await executor.executeAgent("trigger-schedule-agent", undefined, {
        triggerType: "schedule",
      });

      const execs = executor.getExecutions("trigger-schedule-agent");
      expect(execs[0].triggerType).toBe("schedule");
    });
  });
});
