import type { ChildProcessWithoutNullStreams } from "node:child_process";
import { createInterface, type Interface as ReadLineInterface } from "node:readline";

import type { McpServerConfig } from "@cursor/sdk";
import type { SessionRun } from "@codeflowmu/protocol";

import type {
  RunHandle,
  RuntimeEvent,
  RuntimeEventType,
  Unsubscribe,
} from "../types/state.ts";
import { isLifecycleToolNameAllowed } from "../lifecycle/lifecycleToolNames.ts";

type JsonObject = Record<string, unknown>;

interface PendingRequest {
  resolve: (value: unknown) => void;
  reject: (error: Error) => void;
}

export interface CodexAppServerRunHandleOptions {
  sessionId: string;
  agentId: string;
  provider: string;
  model: string;
  workspace: string;
  text: string;
  mcpServers?: Record<string, McpServerConfig>;
  runId?: string;
  maxToolRounds?: number;
}

function asObject(value: unknown): JsonObject | undefined {
  return value && typeof value === "object" && !Array.isArray(value)
    ? value as JsonObject
    : undefined;
}

function itemId(item: JsonObject | undefined): string {
  const value = item?.["id"];
  return typeof value === "string" && value.trim()
    ? value
    : `codex-item-${Math.random().toString(36).slice(2, 10)}`;
}

function stringValue(value: unknown): string | undefined {
  return typeof value === "string" && value.trim() ? value.trim() : undefined;
}

function safePreview(value: unknown, max = 500): string {
  if (value === undefined || value === null) return "unavailable";
  let text: string;
  try {
    text = typeof value === "string" ? value : JSON.stringify(value);
  } catch {
    text = String(value);
  }
  return text.length > max ? `${text.slice(0, max)}…` : text;
}

function codexToolIdentity(
  item: JsonObject | undefined,
  kind: string,
): { name: string; server?: string } {
  const server = stringValue(
    item?.["server"] ?? item?.["serverName"] ?? item?.["mcp_server"],
  );
  const tool = stringValue(
    item?.["tool"] ?? item?.["toolName"] ?? item?.["name"],
  );
  const name = server && tool && !tool.startsWith(`${server}.`)
    ? `${server}.${tool}`
    : tool ?? kind;
  return { name, ...(server ? { server } : {}) };
}

function elicitedToolName(params: JsonObject): string {
  const meta = asObject(params["_meta"]);
  const explicit = meta?.["tool_name"] ?? meta?.["tool"];
  if (typeof explicit === "string" && explicit.trim()) return explicit.trim();
  const message = typeof params["message"] === "string" ? params["message"] : "";
  return /tool\s+["']([^"']+)["']/i.exec(message)?.[1] ?? "";
}

function configuredFcopTools(config: McpServerConfig | undefined): Set<string> {
  const env = asObject(asObject(config)?.["env"]);
  const raw = typeof env?.["FCOP_ALLOWED_TOOLS"] === "string"
    ? env["FCOP_ALLOWED_TOOLS"] as string
    : "";
  return new Set(raw.split(",").map((value) => value.trim()).filter(Boolean));
}

/**
 * App-server turns MCP approval into a client callback. CodeFlowMu accepts
 * only calls to MCP servers mounted for this Agent. FCoP additionally has to
 * match the role-specific FCOP_ALLOWED_TOOLS allowlist emitted by sdk-factory.
 */
export function decideCodexMcpElicitation(
  params: JsonObject,
  mcpServers: Record<string, McpServerConfig> | undefined,
): { action: "accept" | "decline"; content: JsonObject | null; _meta: null } {
  const serverName = typeof params["serverName"] === "string" ? params["serverName"] : "";
  const meta = asObject(params["_meta"]);
  const isToolApproval = meta?.["codex_approval_kind"] === "mcp_tool_call";
  const configured = serverName ? mcpServers?.[serverName] : undefined;
  let allowed = Boolean(configured && isToolApproval);
  if (allowed && serverName === "fcop") {
    const tool = elicitedToolName(params);
    const configuredTools = configuredFcopTools(configured);
    allowed = Boolean(
      tool &&
      (configuredTools.has(tool) ||
        isLifecycleToolNameAllowed(tool, configuredTools)),
    );
  }
  return allowed
    ? { action: "accept", content: {}, _meta: null }
    : { action: "decline", content: null, _meta: null };
}

/**
 * RunHandle for the bidirectional `codex app-server --stdio` protocol.
 *
 * Unlike `codex exec --json`, app-server lets CodeFlowMu resolve MCP approval
 * callbacks, which is required for real FCoP tool use. Only observable
 * reasoning summaries are forwarded; hidden reasoning content is never
 * exposed as `sdk.thinking`.
 */
export class CodexAppServerRunHandle implements RunHandle {
  readonly run_id: string;
  readonly session_id: string;
  readonly agent_id: string;

  readonly #options: CodexAppServerRunHandleOptions;
  readonly #listeners = new Set<(event: RuntimeEvent) => void>();
  readonly #eventBuffer: RuntimeEvent[] = [];
  readonly #startedToolItems = new Set<string>();
  readonly #pending = new Map<number, PendingRequest>();
  readonly #settledPromise: Promise<SessionRun>;
  readonly #startedAt = new Date().toISOString();
  #resolveSettled!: (run: SessionRun) => void;
  #child: ChildProcessWithoutNullStreams | null = null;
  #lines: ReadLineInterface | null = null;
  #nextRequestId = 1;
  #settled = false;
  #cancelled = false;
  #toolCalls = 0;
  #stderr = "";
  #currentTurnId = "unavailable";

  constructor(options: CodexAppServerRunHandleOptions) {
    this.#options = options;
    this.run_id = options.runId ?? `run-codex-${Math.random().toString(36).slice(2, 12)}`;
    this.session_id = options.sessionId;
    this.agent_id = options.agentId;
    this.#settledPromise = new Promise((resolve) => { this.#resolveSettled = resolve; });
  }

  attachProcess(child: ChildProcessWithoutNullStreams): void {
    if (this.#child) throw new Error("CodexAppServerRunHandle: process already attached");
    this.#child = child;
    this.#emit("runtime.session_started", {
      provider: this.#options.provider,
      model: this.#options.model,
      transport: "codex-app-server-stdio",
    });

    this.#lines = createInterface({ input: child.stdout, crlfDelay: Infinity });
    this.#lines.on("line", (line) => this.#acceptLine(line));
    child.stderr.on("data", (chunk: Buffer | string) => {
      const text = chunk.toString();
      this.#stderr += text;
      if (text.trim()) this.#emit("sdk.status", { status: "stderr", text: text.trim() });
    });
    child.on("error", (error) => this.#fail("CODEX_APP_SERVER_SPAWN_ERROR", error.message));
    child.on("close", (code) => {
      this.#lines?.close();
      if (this.#settled) return;
      if (this.#cancelled) {
        this.#finish("cancelled", "CODEX_APP_SERVER_CANCELLED");
      } else {
        this.#fail(
          "CODEX_APP_SERVER_EXIT_EARLY",
          this.#stderr.trim() || `codex app-server exited before turn completion (${code ?? "unknown"})`,
        );
      }
    });
    void this.#bootstrap().catch((error) => {
      this.#fail(
        "CODEX_APP_SERVER_BOOTSTRAP_FAILED",
        error instanceof Error ? error.message : String(error),
      );
    });
  }

  /** Unit-test seam for protocol responses, callbacks and notifications. */
  acceptMessageForTest(message: JsonObject): void {
    this.#acceptMessage(message);
  }

  isActive(): boolean { return !this.#settled; }

  async cancel(reason: string): Promise<void> {
    if (this.#settled) return;
    this.#cancelled = true;
    this.#emit("runtime.session_cancelled", { reason });
    this.#shutdownProcess();
    this.#finish("cancelled", "CODEX_APP_SERVER_CANCELLED");
  }

  whenSettled(): Promise<SessionRun> { return this.#settledPromise; }

  onEvent(listener: (event: RuntimeEvent) => void): Unsubscribe {
    const first = this.#listeners.size === 0;
    this.#listeners.add(listener);
    if (first && this.#eventBuffer.length > 0) {
      const buffered = this.#eventBuffer.splice(0);
      for (const event of buffered) this.#deliver(event);
    }
    return () => this.#listeners.delete(listener);
  }

  async #bootstrap(): Promise<void> {
    await this.#request("initialize", {
      clientInfo: { name: "codeflowmu", title: "CodeFlowMu", version: "1.3.1" },
      capabilities: { experimentalApi: true, requestAttestation: false },
    });
    this.#send({ method: "initialized" });
    const started = asObject(await this.#request("thread/start", {
      model: this.#options.model,
      modelProvider: "openai",
      cwd: this.#options.workspace,
      runtimeWorkspaceRoots: [this.#options.workspace],
      approvalPolicy: "never",
      approvalsReviewer: "auto_review",
      sandbox: "workspace-write",
      ephemeral: true,
    }));
    const thread = asObject(started?.["thread"]);
    const threadId = typeof thread?.["id"] === "string" ? thread["id"] : "";
    if (!threadId) throw new Error("thread/start returned no thread id");
    await this.#request("turn/start", {
      threadId,
      input: [{ type: "text", text: this.#options.text, text_elements: [] }],
    });
  }

  #request(method: string, params: unknown): Promise<unknown> {
    const id = this.#nextRequestId++;
    this.#send({ method, id, params });
    return new Promise((resolve, reject) => this.#pending.set(id, { resolve, reject }));
  }

  #respond(id: unknown, result: unknown): void { this.#send({ id, result }); }

  #send(message: JsonObject): void {
    if (!this.#child?.stdin.writable) throw new Error("codex app-server stdin is not writable");
    this.#child.stdin.write(`${JSON.stringify(message)}\n`);
  }

  #acceptLine(line: string): void {
    const trimmed = line.trim();
    if (!trimmed) return;
    try {
      this.#acceptMessage(JSON.parse(trimmed) as JsonObject);
    } catch {
      this.#emit("sdk.status", { status: "stdout", text: trimmed });
    }
  }

  #acceptMessage(message: JsonObject): void {
    const hasId = Object.hasOwn(message, "id");
    const method = typeof message["method"] === "string" ? message["method"] as string : "";
    if (hasId && !method) {
      const id = typeof message["id"] === "number" ? message["id"] : Number(message["id"]);
      const waiter = this.#pending.get(id);
      if (!waiter) return;
      this.#pending.delete(id);
      if (message["error"]) waiter.reject(new Error(JSON.stringify(message["error"])));
      else waiter.resolve(message["result"]);
      return;
    }
    if (hasId && method) {
      this.#handleServerRequest(message["id"], method, asObject(message["params"]) ?? {});
      return;
    }
    if (method) this.#handleNotification(method, asObject(message["params"]) ?? {});
  }

  #handleServerRequest(id: unknown, method: string, params: JsonObject): void {
    if (method === "mcpServer/elicitation/request") {
      this.#respond(id, decideCodexMcpElicitation(params, this.#options.mcpServers));
      return;
    }
    if (method === "item/commandExecution/requestApproval") {
      this.#respond(id, { decision: "decline" });
      return;
    }
    if (method === "item/fileChange/requestApproval") {
      this.#respond(id, { decision: "decline" });
      return;
    }
    if (method === "execCommandApproval" || method === "applyPatchApproval") {
      this.#respond(id, { decision: "denied" });
      return;
    }
    if (method === "item/tool/requestUserInput") {
      this.#respond(id, { answers: {} });
      return;
    }
    this.#respond(id, { error: `unsupported CodeFlowMu callback: ${method}` });
  }

  #handleNotification(method: string, params: JsonObject): void {
    if (method === "turn/started") {
      this.#currentTurnId =
        stringValue(asObject(params["turn"])?.["id"] ?? params["turnId"]) ??
        "unavailable";
      this.#emit("sdk.status", { status: "turn_started", turn_id: this.#currentTurnId });
      return;
    }
    if (method === "turn/completed") {
      const turn = asObject(params["turn"]);
      if (turn?.["status"] === "failed") {
        this.#fail("CODEX_APP_SERVER_TURN_FAILED", JSON.stringify(turn["error"] ?? turn));
        return;
      }
      this.#emit("sdk.result", {
        status: "finished",
        provider: this.#options.provider,
        model: this.#options.model,
        terminal_reason: "turn_completed",
        turn_id: turn?.["id"],
      });
      this.#finish("finished");
      this.#shutdownProcess();
      return;
    }
    if (method === "error") {
      this.#fail("CODEX_APP_SERVER_ERROR", JSON.stringify(params));
      return;
    }
    if (method === "item/reasoning/summaryTextDelta") {
      const delta = typeof params["delta"] === "string" ? params["delta"] : "";
      if (delta) this.#emit("sdk.status", {
        status: "reasoning_summary_delta",
        text: delta,
        item_id: params["itemId"],
        observable_summary: true,
      });
      return;
    }
    if (method === "item/started" || method === "item/completed") {
      this.#handleItem(method, asObject(params["item"]), params);
      return;
    }
    if (method === "item/mcpToolCall/progress") {
      this.#emit("sdk.status", { status: "mcp_progress", raw: params });
    }
  }

  #handleItem(
    method: string,
    item: JsonObject | undefined,
    params: JsonObject,
  ): void {
    const kind = typeof item?.["type"] === "string" ? item["type"] as string : "unknown";
    const id = itemId(item);
    if (kind === "agentMessage" && method === "item/completed") {
      const text = typeof item?.["text"] === "string" ? item["text"] : "";
      if (text) this.#emit("sdk.assistant", { text, item_id: id });
      return;
    }
    if (kind === "reasoning" && method === "item/completed") {
      const summary = Array.isArray(item?.["summary"])
        ? item?.["summary"].filter((value): value is string => typeof value === "string").join("\n")
        : "";
      if (summary) this.#emit("sdk.status", {
        status: "reasoning_summary",
        text: summary,
        item_id: id,
        observable_summary: true,
      });
      return;
    }

    const isTool = [
      "commandExecution", "mcpToolCall", "dynamicToolCall", "fileChange", "webSearch",
    ].includes(kind);
    if (!isTool) return;
    const identity = codexToolIdentity(item, kind);
    const roundId =
      stringValue(params["turnId"] ?? params["turn_id"] ?? item?.["turnId"]) ??
      this.#currentTurnId;
    const args =
      item?.["arguments"] ?? item?.["args"] ?? item?.["input"] ?? item?.["command"];
    if (!this.#startedToolItems.has(id)) {
      this.#startedToolItems.add(id);
      this.#toolCalls += 1;
      this.#emit("sdk.tool_call", {
        call_id: id,
        name: identity.name,
        tool_name: identity.name,
        ...(identity.server ? { server: identity.server } : {}),
        round_id: roundId,
        arguments_summary: safePreview(args),
        started_at: new Date().toISOString(),
        item,
      });
    }
    if (method === "item/completed") {
      const failed = item?.["status"] === "failed" || item?.["error"] != null || item?.["success"] === false;
      const duration = item?.["durationMs"] ?? item?.["duration_ms"];
      const exitStatus =
        item?.["exitCode"] ?? item?.["exit_code"] ?? item?.["status"] ?? "unavailable";
      const stderr = item?.["stderr"] ?? item?.["standardError"];
      const result =
        item?.["result"] ?? item?.["output"] ?? item?.["aggregatedOutput"];
      this.#emit("sdk.tool_result", {
        call_id: id,
        name: identity.name,
        tool_name: identity.name,
        ...(identity.server ? { server: identity.server } : {}),
        round_id: roundId,
        result_summary: safePreview(result),
        duration_ms: typeof duration === "number" ? duration : "unavailable",
        timeout: "unavailable",
        exit_status: exitStatus,
        stderr_summary: safePreview(stderr),
        failure_reason: failed
          ? safePreview(item?.["error"] ?? item?.["failureReason"] ?? "unknown")
          : "unavailable",
        ended_at: new Date().toISOString(),
        item,
        ok: !failed,
      });
    }
    if (this.#options.maxToolRounds != null && this.#toolCalls > this.#options.maxToolRounds) {
      void this.cancel(`Codex app-server tool round limit exceeded (${this.#options.maxToolRounds})`);
    }
  }

  #fail(failureCode: string, error: string): void {
    if (this.#settled) return;
    this.#emit("sdk.result", {
      status: "failed",
      failure_code: failureCode,
      error,
      stderr: this.#stderr,
      provider: this.#options.provider,
      model: this.#options.model,
    });
    this.#finish("failed", failureCode);
    this.#shutdownProcess();
  }

  #shutdownProcess(): void {
    for (const waiter of this.#pending.values()) {
      waiter.reject(new Error("codex app-server stopped"));
    }
    this.#pending.clear();
    try { this.#child?.stdin.end(); } catch { /* best effort */ }
    if (this.#child && !this.#child.killed) {
      try { this.#child.kill("SIGTERM"); } catch { /* best effort on Windows */ }
    }
  }

  #finish(status: SessionRun["status"], failureCode?: string): void {
    if (this.#settled) return;
    this.#settled = true;
    this.#resolveSettled({
      run_id: this.run_id,
      started_at: this.#startedAt,
      ended_at: new Date().toISOString(),
      status,
      tool_calls_count: this.#toolCalls,
      ...(failureCode ? { failure_code: failureCode } : {}),
    });
  }

  #emit(eventType: RuntimeEventType, payload?: unknown): void {
    const event: RuntimeEvent = {
      event_id: `evt-${Math.random().toString(36).slice(2, 10)}`,
      at: new Date().toISOString(),
      event_type: eventType,
      session_id: this.session_id,
      run_id: this.run_id,
      agent_id: this.agent_id,
      payload,
    };
    if (this.#listeners.size === 0) this.#eventBuffer.push(event);
    else this.#deliver(event);
  }

  #deliver(event: RuntimeEvent): void {
    for (const listener of this.#listeners) {
      try { listener(event); } catch { /* listeners are isolated */ }
    }
  }
}
