/**
 * Claude Code SDK agent runner.
 *
 * Uses `@anthropic-ai/claude-agent-sdk`'s `query()` as the agent loop. The SDK
 * owns tool dispatch, context compaction, streaming — we just map its events
 * to the Feishu streaming callback.
 *
 * Built-in tools (Read, Write, Bash, Glob, Grep) cover our entire tool surface:
 *  - read_file → Read
 *  - write_file → Write
 *  - list_files → Glob
 *  - cph_check / cph_build → Bash (`cph check .`, `cph build . --target student`)
 * No custom tools needed — the SDK's Bash tool runs cph directly.
 *
 * ADR-0017 update: this drops provider-agnosticism. Claude Code SDK is
 * Anthropic-specific. The tradeoff: we get compaction, tool approval, partial
 * message streaming, and a battle-tested agent loop — capabilities we'd have
 * to build ourselves with a generic provider.
 *
 * ADR-0018: the agent execution surface is bounded by the run's workspace.
 * The SDK sandbox (bubblewrap on Linux, seatbelt on macOS) confines the Claude
 * Code process and its subprocesses at the OS level — writes, including the
 * SDK's Unix-socket scratch, are confined to the workspace. Host reads are
 * denied by default and re-opened only for the workspace plus named system
 * runtimes, and `failIfUnavailable` hard-fails if the sandbox can't start. The
 * subprocess gets a minimal environment and SDK credential protection removes
 * provider secrets from Bash. This upholds the workspace-bounded file-op
 * invariant (ADR-0018) without re-implementing the
 * `workspace.ts` `confine()` path validator as a tool wrapper — the OS sandbox
 * is the mechanism, the ADR pins the invariant.
 */
import { query, type HookCallback, type McpServerConfig, type SDKMessage, type SDKAssistantMessage, type SDKUserMessage, type SDKResultMessage, type SDKPartialAssistantMessage, type SDKSystemMessage } from "@anthropic-ai/claude-agent-sdk";
import type { PrismaClient } from "@prisma/client";
import { claudeSdkToolConfigForRole } from "./roleTools.js";
import { createAgentSecurityPolicy } from "./security.js";
import type { RoleSkillEntry } from "./models.js";

export interface ProjectContext {
  readonly projectId: string;
  readonly boundChatId: string;
  readonly workspaceRoot?: string | undefined;
  readonly workspaceDir: string;
}

export type StreamEvent =
  | { readonly type: "text-delta"; readonly text: string }
  | { readonly type: "thinking-delta"; readonly text: string }
  | { readonly type: "tool-start"; readonly toolName: string; readonly toolUseId: string }
  | { readonly type: "tool-end"; readonly toolName: string; readonly toolUseId: string; readonly input: unknown; readonly durationMs?: number }
  | { readonly type: "tool-result"; readonly toolUseId: string; readonly toolName: string; readonly result: string; readonly isError: boolean; readonly durationMs?: number }
  | { readonly type: "finish" };

export type StreamCallback = (event: StreamEvent) => void;

export interface RunRequest {
  readonly prompt: string;
  readonly model: string | undefined;
  readonly project: ProjectContext;
  readonly systemPrompt: string | undefined;
  /** Run-scoped loopback proxy capability; never an Organization provider credential. */
  readonly providerProxyEnv?: Record<string, string | undefined> | undefined;
  readonly resumeSessionId?: string | undefined;
  /**
   * RoleEntry.tools from admin/runtime settings. Values are Hub role tool ids
   * (for example read_file/cph_check/send_file), mapped to Claude SDK tool
   * names at run setup. `undefined` means the full supported tool surface; []
   * means no tools.
   */
  readonly tools?: readonly string[] | undefined;
  readonly skills?: readonly RoleSkillEntry[] | undefined;
  readonly mcpServers?: Record<string, McpServerConfig> | undefined;
  readonly maxTurns?: number;
  readonly runId: string;
  readonly sessionId: string;
  readonly prisma: PrismaClient;
  readonly onStream?: StreamCallback;
  /** Optional diagnostic sink for Claude SDK subprocess stderr. */
  readonly onSdkStderr?: ((data: string) => void) | undefined;
  /**
   * Abort controller for the run. Aborting the signal interrupts the SDK query
   * (it stops the agent loop and cleans up); the run resolves with status
   * "interrupted". The caller owns the controller and triggers abort.
   */
  readonly abortController?: AbortController | undefined;
}
export type RunStatus = "completed" | "length" | "failed" | "interrupted";

export interface RunResult {
  readonly status: RunStatus;
  readonly text: string;
  readonly usage: { inputTokens: number; outputTokens: number };
  /** Provider- or gateway-reported cost for this run, in USD. */
  readonly costUsd?: number | undefined;
  readonly numTurns: number;
  readonly sdkSessionId?: string | undefined;
  /** Skill ids reported by the SDK init event, not merely requested options. */
  readonly initializedSkillIds?: readonly string[] | undefined;
  readonly error?: string;
}

const DEFAULT_MAX_TURNS = 25;

const denyUnsandboxedBash: HookCallback = async (input) => {
  if (input.hook_event_name !== "PreToolUse" || input.tool_name !== "Bash") return {};
  const toolInput = input.tool_input;
  if (
    typeof toolInput !== "object" || toolInput === null ||
    !("dangerouslyDisableSandbox" in toolInput) || toolInput.dangerouslyDisableSandbox !== true
  ) return {};
  return {
    hookSpecificOutput: {
      hookEventName: "PreToolUse",
      permissionDecision: "deny",
      permissionDecisionReason: "This deployment requires every Bash command to remain sandboxed.",
    },
  };
};

export async function runAgent(req: RunRequest): Promise<RunResult> {
  const onStream = req.onStream;
  const cap = req.maxTurns ?? DEFAULT_MAX_TURNS;

  // Lock heartbeat: refresh on each step. Best-effort.
  const heartbeat = async () => {
    try {
      await req.prisma.projectAgentLock.update({
        where: { runId: req.runId },
        data: { heartbeatAt: new Date() },
      });
    } catch { /* best-effort */ }
  };

  let fullText = "";
  let usage = { inputTokens: 0, outputTokens: 0 };
  let costUsd: number | undefined;
  let numTurns = 0;
  let sdkSessionId: string | undefined;
  let initializedSkillIds: readonly string[] | undefined;
  let error: string | undefined;
  let cleanupSecurity = async (): Promise<void> => {};
  try {
    await persistAgentMessage(req, "user", req.prompt);
    const toolConfig = claudeSdkToolConfigForRole(req.tools);
    const workspaceRoot = req.project.workspaceRoot?.trim();
    if (workspaceRoot === undefined || workspaceRoot === "") {
      throw new Error("Agent run requires the configured workspace root");
    }
    const security = await createAgentSecurityPolicy({
      runId: req.runId,
      workspaceRoot,
      workspaceDir: req.project.workspaceDir,
      skills: req.skills,
      providerProxyEnv: req.providerProxyEnv,
    });
    cleanupSecurity = security.cleanup;
    const hasSkills = security.skillIds.length > 0;

    type QueryOptions = NonNullable<Parameters<typeof query>[0]["options"]>;
    const options: QueryOptions = {
      cwd: security.cwd,
      // `skills` controls discovery/allowlisting, but an explicit `tools`
      // list still has to expose the Skill dispatcher itself.
      tools: [...toolConfig.tools, ...(hasSkills ? ["Skill"] : [])],
      allowedTools: [...toolConfig.allowedTools],
      maxTurns: cap,
      includePartialMessages: true,
      // ADR-0018: bypass interactive prompts (headless server); the sandbox
      // below is the hard boundary, not the permission-prompt layer.
      permissionMode: "bypassPermissions" as const,
      allowDangerouslySkipPermissions: true,
      // ADR-0018: OS-level sandbox confines the agent process + its Bash
      // subprocesses. Writes and ordinary reads are confined to the workspace;
      // named system runtime files remain readable so cph can execute. Network
      // stays open. failIfUnavailable and allowUnsandboxedCommands=false make
      // sandbox loss a hard failure.
      sandbox: { ...security.sandbox },
      env: security.env,
      // The project workspace is untrusted input. Do not load user/project
      // settings that could widen tools, hooks, MCP servers, or sandbox paths.
      settingSources: [],
      settings: { disableBundledSkills: true },
      ...(hasSkills && security.skillPluginRoot !== undefined
        ? { plugins: [{ type: "local" as const, path: security.skillPluginRoot, skipMcpDiscovery: true }] }
        : {}),
      skills: [...security.skillIds],
      strictMcpConfig: true,
      // Claude Code 2.1.202 can honor the per-call opt-out despite
      // sandbox.allowUnsandboxedCommands=false. Enforce the invariant again at
      // the PreToolUse boundary, before the Bash process can be spawned.
      hooks: {
        PreToolUse: [{ matcher: "Bash", hooks: [denyUnsandboxedBash] }],
      },
    };
    if (req.systemPrompt !== undefined) options.systemPrompt = req.systemPrompt;
    if (req.model !== undefined) options.model = req.model;
    if (req.resumeSessionId !== undefined) options.resume = req.resumeSessionId;
    if (req.mcpServers !== undefined) options.mcpServers = req.mcpServers;
    if (req.abortController !== undefined) options.abortController = req.abortController;
    if (req.onSdkStderr !== undefined) options.stderr = req.onSdkStderr;

    const conversation = query({
      prompt: req.prompt,
      options,
    });

    // Track tool start timestamps for duration calculation
    const toolStartTimestamps = new Map<string, number>();

    for await (const message of conversation) {
      switch (message.type) {
        case "system": {
          if (message.subtype === "init") {
            initializedSkillIds = [...(message as SDKSystemMessage).skills];
          }
          break;
        }
        case "stream_event": {
          const evt = (message as SDKPartialAssistantMessage).event;
          if (evt.type === "content_block_delta" && evt.delta.type === "text_delta") {
            onStream?.({ type: "text-delta", text: evt.delta.text });
          }
          if (evt.type === "content_block_delta" && evt.delta.type === "thinking_delta") {
            onStream?.({ type: "thinking-delta", text: evt.delta.thinking });
          }
          if (evt.type === "content_block_start" && evt.content_block.type === "tool_use") {
            const toolUseId = evt.content_block.id;
            toolStartTimestamps.set(toolUseId, Date.now());
            onStream?.({ type: "tool-start", toolName: evt.content_block.name, toolUseId });
          }
          if (evt.type === "content_block_stop") {
            // Tool end is attributed from the assistant message below.
          }
          break;
        }
        case "assistant": {
          const msg = (message as SDKAssistantMessage).message;
          await heartbeat();
          numTurns++;
          let assistantText = "";
          for (const block of msg.content) {
            if (block.type === "text" && typeof block.text === "string") {
              fullText += block.text;
              assistantText += block.text;
            }
            if (block.type === "tool_use") {
              const durationMs = toolStartTimestamps.has(block.id)
                ? Date.now() - (toolStartTimestamps.get(block.id) ?? 0)
                : undefined;
              onStream?.({
                type: "tool-end",
                toolName: block.name,
                toolUseId: block.id,
                input: block.input,
                ...(durationMs !== undefined ? { durationMs } : {}),
              });
            }
            if (block.type === "thinking" && typeof block.thinking === "string") {
              // Complete thinking block from the assistant message.
              // Stream deltas already delivered this; no separate event needed.
            }
          }
          await persistAgentMessage(req, "assistant", assistantText);
          if (msg.usage !== undefined) {
            usage.inputTokens += msg.usage.input_tokens ?? 0;
            usage.outputTokens += msg.usage.output_tokens ?? 0;
          }
          break;
        }
        case "user": {
          // User messages carry tool_result blocks — what each tool returned.
          const msg = (message as SDKUserMessage).message;
          const content = msg.content;
          if (!Array.isArray(content)) break;
          for (const block of content) {
            if (typeof block !== "object" || block === null || block.type !== "tool_result") continue;
            // Narrowed to BetaToolResultBlockParam by discriminant + field presence
            if (!("tool_use_id" in block)) continue;
            const toolUseId = block.tool_use_id;
            const isError = block.is_error === true;
            const resultText = extractToolResultText(block.content);
            const durationMs = toolStartTimestamps.get(toolUseId);
            onStream?.({
              type: "tool-result",
              toolUseId,
              toolName: toolUseId,
              result: resultText,
              isError,
              ...(durationMs !== undefined ? { durationMs: Date.now() - durationMs } : {}),
            });
          }
          break;
        }
        case "result": {
          const result = message as SDKResultMessage;
          sdkSessionId = result.session_id;
          if (result.subtype === "success" && fullText === "" && typeof result.result === "string") {
            fullText = result.result;
          }
          costUsd = Number.isFinite(result.total_cost_usd) ? result.total_cost_usd : undefined;
          if (result.subtype !== "success") {
            error = `result_${result.subtype}`;
          }
          break;
        }
      }
    }

    onStream?.({ type: "finish" });
    return {
      status: error !== undefined ? "failed" : "completed",
      text: fullText,
      usage,
      ...(costUsd !== undefined ? { costUsd } : {}),
      numTurns,
      sdkSessionId,
      ...(initializedSkillIds !== undefined ? { initializedSkillIds } : {}),
      ...(error !== undefined ? { error } : {}),
    };
  } catch (e) {
    const aborted = req.abortController?.signal.aborted === true;
    return {
      status: aborted ? "interrupted" : "failed",
      text: fullText,
      usage,
      ...(costUsd !== undefined ? { costUsd } : {}),
      numTurns,
      sdkSessionId,
      ...(initializedSkillIds !== undefined ? { initializedSkillIds } : {}),
      ...(aborted ? {} : { error: e instanceof Error ? e.message : String(e) }),
    };
  } finally {
    await cleanupSecurity();
  }
}

async function persistAgentMessage(req: RunRequest, role: string, content: string): Promise<void> {
  if (content === "") return;
  try {
    await req.prisma.agentMessage.create({
      data: {
        sessionId: req.sessionId,
        runId: req.runId,
        role,
        content,
        attachments: [],
      },
    });
  } catch {
    // Best-effort projection: a history write failure must not break the run.
  }
}

function extractToolResultText(content: unknown): string {
  if (typeof content === "string") return content;
  if (!Array.isArray(content)) return "";
  const parts: string[] = [];
  for (const part of content) {
    if (typeof part === "object" && part !== null && "type" in part && part.type === "text" && "text" in part && typeof part.text === "string") {
      parts.push(part.text);
    }
  }
  return parts.join("\n");
}
