import { spawn } from "node:child_process";
import { mkdirSync, writeFileSync } from "node:fs";
import { homedir } from "node:os";
import { join } from "node:path";
import { log } from "../utils/cli.ts";
import { addInstructions } from "./instructions.ts";
import {
  agent,
  type ConfigureMcpServersParams,
  createAgentEnv,
  installFromCurl,
} from "./shared.ts";

// cursor cli event types inferred from stream-json output
interface CursorSystemEvent {
  type: "system";
  subtype?: string;
  [key: string]: unknown;
}

interface CursorUserEvent {
  type: "user";
  message?: {
    role: string;
    content: Array<{ type: string; text?: string }>;
  };
  [key: string]: unknown;
}

interface CursorThinkingEvent {
  type: "thinking";
  subtype: "delta" | "completed";
  text?: string;
  [key: string]: unknown;
}

interface CursorAssistantEvent {
  type: "assistant";
  model_call_id?: string;
  message?: {
    role: string;
    content: Array<{ type: string; text?: string }>;
  };
  [key: string]: unknown;
}

interface CursorToolCallEvent {
  type: "tool_call";
  subtype: "started" | "completed";
  call_id?: string;
  tool_call?: {
    mcpToolCall?: {
      args?: {
        name?: string;
        args?: unknown;
        toolName?: string;
        providerIdentifier?: string;
      };
      result?: {
        success?: {
          content?: Array<{ text?: { text?: string } }>;
          isError?: boolean;
        };
      };
    };
  };
  [key: string]: unknown;
}

interface CursorResultEvent {
  type: "result";
  subtype: "success" | "error";
  result?: string;
  duration_ms?: number;
  [key: string]: unknown;
}

type CursorEvent =
  | CursorSystemEvent
  | CursorUserEvent
  | CursorThinkingEvent
  | CursorAssistantEvent
  | CursorToolCallEvent
  | CursorResultEvent;

export const cursor = agent({
  name: "cursor",
  install: async () => {
    return await installFromCurl({
      installUrl: "https://cursor.com/install",
      executableName: "cursor-agent",
    });
  },
  run: async ({ payload, apiKey, cliPath, mcpServers, repo }) => {
    configureCursorMcpServers({ mcpServers, cliPath });
    configureCursorSandbox({ sandbox: payload.sandbox ?? false, isPublicRepo: repo.isPublic });

    // track logged model_call_ids to avoid duplicates
    // cursor emits each assistant message twice: once without model_call_id, then again with it
    const loggedModelCallIds = new Set<string>();

    const messageHandlers = {
      system: (_event: CursorSystemEvent) => {
        // system init events - no logging needed
      },
      user: (_event: CursorUserEvent) => {
        // user messages already logged in prompt box
      },
      thinking: (_event: CursorThinkingEvent) => {
        // thinking events are internal - no logging needed
      },
      assistant: (event: CursorAssistantEvent) => {
        const text = event.message?.content?.[0]?.text?.trim();
        if (!text) return;

        if (event.model_call_id) {
          // complete message with model_call_id - log it if we haven't seen this id before
          // cursor emits each message twice: first without model_call_id, then with it
          // we deduplicate by model_call_id to avoid logging the same message twice
          if (!loggedModelCallIds.has(event.model_call_id)) {
            loggedModelCallIds.add(event.model_call_id);
            log.box(text, { title: "Cursor" });
          }
        } else {
          // message without model_call_id - log it immediately
          // this handles cases where:
          // 1. the final summary message might only be emitted without model_call_id
          // 2. messages that don't get re-emitted with model_call_id
          // without this, the final comprehensive summary wouldn't print (as we discovered)
          log.box(text, { title: "Cursor" });
        }
      },
      tool_call: (event: CursorToolCallEvent) => {
        if (event.subtype === "started") {
          // handle both MCP tools and built-in tools (bash, WebFetch, etc)
          const mcpToolCall = event.tool_call?.mcpToolCall;
          const builtinToolCall = (event.tool_call as any)?.builtinToolCall;

          if (mcpToolCall?.args?.toolName && mcpToolCall?.args?.args) {
            log.toolCall({
              toolName: mcpToolCall.args.toolName,
              input: mcpToolCall.args.args,
            });
          } else if (builtinToolCall?.args?.name && builtinToolCall?.args?.args) {
            log.toolCall({
              toolName: builtinToolCall.args.name,
              input: builtinToolCall.args.args,
            });
          }
        } else if (event.subtype === "completed") {
          const isError = event.tool_call?.mcpToolCall?.result?.success?.isError;
          if (isError) {
            log.warning("Tool call failed");
          }
        }
      },
      result: async (event: CursorResultEvent) => {
        if (event.subtype === "success" && event.duration_ms) {
          const durationSec = (event.duration_ms / 1000).toFixed(1);
          log.debug(`Cursor completed in ${durationSec}s`);
          // note: we don't log event.result here because it contains the full conversation
          // concatenated together, which would duplicate all the individual assistant
          // messages we've already logged. the individual assistant events are sufficient.
        }
      },
    };

    try {
      const fullPrompt = addInstructions({ payload, repo });
      log.group("Full prompt", () => log.info(fullPrompt));

      // configure sandbox mode if enabled
      // in sandbox mode: remove --force flag and rely on cli-config.json sandbox settings
      const cursorArgs = payload.sandbox
        ? [
            "--print",
            fullPrompt,
            "--output-format",
            "stream-json",
            "--approve-mcps",
            // --force removed in sandbox mode to enforce safety checks
          ]
        : ["--print", fullPrompt, "--output-format", "stream-json", "--approve-mcps", "--force"];

      if (payload.sandbox) {
        log.info("🔒 sandbox mode enabled: restricting to read-only operations");
      }

      log.info("Running Cursor CLI...");

      const startTime = Date.now();

      return new Promise((resolve) => {
        const child = spawn(cliPath, cursorArgs, {
          cwd: process.cwd(),
          env: createAgentEnv({
            CURSOR_API_KEY: apiKey,
          }),
          stdio: ["ignore", "pipe", "pipe"], // Ignore stdin, pipe stdout/stderr
        });

        let stdout = "";
        let stderr = "";

        child.on("spawn", () => {
          log.debug("Cursor CLI process spawned");
        });

        child.stdout?.on("data", async (data) => {
          const text = data.toString();
          stdout += text;

          try {
            const event = JSON.parse(text) as CursorEvent;
            log.debug(JSON.stringify(event, null, 2));

            // skip empty thinking deltas
            if (event.type === "thinking" && event.subtype === "delta" && !event.text) {
              return;
            }

            // route to appropriate handler
            const handler = messageHandlers[event.type as keyof typeof messageHandlers];
            if (handler) {
              await handler(event as never);
            }
          } catch {
            // ignore parse errors - might be formatted tool call logs from cursor cli
            // our handlers log tool calls instead, so we don't need to display these
          }
        });

        child.stderr?.on("data", (data) => {
          const text = data.toString();
          stderr += text;
          process.stderr.write(text);
          log.warning(text);
        });

        child.on("close", async (code, signal) => {
          if (signal) {
            log.warning(`Cursor CLI terminated by signal: ${signal}`);
          }

          const duration = ((Date.now() - startTime) / 1000).toFixed(1);

          if (code === 0) {
            log.success(`Cursor CLI completed successfully in ${duration}s`);
            resolve({
              success: true,
              output: stdout.trim(),
            });
          } else {
            const errorMessage = stderr || `Cursor CLI exited with code ${code}`;
            log.error(`Cursor CLI failed after ${duration}s: ${errorMessage}`);
            resolve({
              success: false,
              error: errorMessage,
              output: stdout.trim(),
            });
          }
        });

        child.on("error", (error) => {
          const duration = ((Date.now() - startTime) / 1000).toFixed(1);
          const errorMessage = error.message || String(error);
          log.error(`Cursor CLI execution failed after ${duration}s: ${errorMessage}`);
          resolve({
            success: false,
            error: errorMessage,
            output: stdout.trim(),
          });
        });
      });
    } catch (error) {
      const errorMessage = error instanceof Error ? error.message : String(error);
      log.error(`Cursor execution failed: ${errorMessage}`);
      return {
        success: false,
        error: errorMessage,
        output: "",
      };
    }
  },
});

// There was an issue on macOS when you set HOME to a temp directory
// it was unable to find the macOS keychain and would fail
// temp solution is to stick with the actual $HOME
function configureCursorMcpServers({ mcpServers }: ConfigureMcpServersParams) {
  const realHome = homedir();
  const cursorConfigDir = join(realHome, ".cursor");
  const mcpConfigPath = join(cursorConfigDir, "mcp.json");
  mkdirSync(cursorConfigDir, { recursive: true });

  // Convert to Cursor's expected format (HTTP config)
  const cursorMcpServers: Record<string, { type: string; url: string }> = {};
  for (const [serverName, serverConfig] of Object.entries(mcpServers)) {
    if (serverConfig.type !== "http") {
      throw new Error(
        `Unsupported MCP server type for Cursor: ${(serverConfig as any).type || "unknown"}`
      );
    }

    cursorMcpServers[serverName] = {
      type: "http",
      url: serverConfig.url,
    };
  }

  writeFileSync(mcpConfigPath, JSON.stringify({ mcpServers: cursorMcpServers }, null, 2), "utf-8");
  log.info(`» MCP config written to ${mcpConfigPath}`);
}

/**
 * Configure Cursor CLI sandbox mode via cli-config.json.
 *
 * SECURITY: For PUBLIC repos, Cursor spawns subprocesses with full process.env, leaking API keys.
 * We deny native Shell via Shell(*) rule, forcing use of MCP bash tool which
 * filters secrets. Note: Shell(**) does NOT work, must use Shell(*).
 * For private repos, native Shell is allowed.
 *
 * Config path: $XDG_CONFIG_HOME/cursor/ (not ~/.cursor/) because createAgentEnv
 * sets XDG_CONFIG_HOME=$HOME/.config. See issues/cursor-perms.md.
 */
function configureCursorSandbox({
  sandbox,
  isPublicRepo,
}: {
  sandbox: boolean;
  isPublicRepo: boolean;
}): void {
  const realHome = homedir();
  const cursorConfigDir = join(realHome, ".config", "cursor");
  const cliConfigPath = join(cursorConfigDir, "cli-config.json");
  mkdirSync(cursorConfigDir, { recursive: true });

  // deny native shell for public repos to prevent secret leakage
  const denyShell = isPublicRepo ? ["Shell(*)"] : [];

  const config = sandbox
    ? {
        permissions: {
          allow: ["Read(**)"],
          deny: ["Write(**)", ...denyShell],
        },
      }
    : {
        permissions: {
          allow: ["Read(**)", "Write(**)"],
          deny: denyShell,
        },
      };

  writeFileSync(cliConfigPath, JSON.stringify(config, null, 2), "utf-8");
  log.info(
    `» CLI config written to ${cliConfigPath} (sandbox: ${sandbox}, isPublicRepo: ${isPublicRepo})`
  );
}
