import type { AgentEvent, AgentSession, CreateSessionOptions } from "@grackle-ai/runtime-sdk";
import { BaseAgentSession, BaseAgentRuntime, AsyncQueue, logger, ensureRuntimeInstalled, importFromRuntime } from "@grackle-ai/runtime-sdk";
import { accessSync, mkdirSync, copyFileSync, chmodSync, constants as fsConstants } from "node:fs";
import { join } from "node:path";
import { homedir, tmpdir } from "node:os";

// Dynamic import — try @anthropic-ai/claude-agent-sdk first, then @anthropic-ai/claude-code
type QueryFn = (opts: Record<string, unknown>) => Promise<unknown>;
let queryFn: QueryFn | undefined = undefined;

async function getQuery(): Promise<QueryFn> {
  if (queryFn) return queryFn;
  await ensureRuntimeInstalled("claude-code");
  // Try the agent SDK first (the proper library package)
  const errors: Array<{ package: string; error: unknown }> = [];
  for (const pkg of ["@anthropic-ai/claude-agent-sdk", "@anthropic-ai/claude-code"]) {
    try {
      const mod = await importFromRuntime<Record<string, unknown>>("claude-code", pkg);
      if (typeof mod.query === "function") {
        queryFn = mod.query as QueryFn;
        return queryFn;
      }
    } catch (err: unknown) {
      logger.warn({ err, package: pkg }, "Failed to import Claude runtime package");
      errors.push({ package: pkg, error: err });
    }
  }
  const details = errors
    .map((e) => `  ${e.package}: ${e.error instanceof Error ? e.error.message : String(e.error)}`)
    .join("\n");
  throw new Error(
    `Claude Agent SDK not installed or failed to load.\n${details}\n`
    + `Run: npm install @anthropic-ai/claude-agent-sdk`,
  );
}

/** Returns true if `v` is a finite number >= 0 (guards against NaN/Infinity from untyped SDK messages). */
function isFiniteNonNegative(v: number | undefined): v is number {
  return typeof v === "number" && Number.isFinite(v) && v >= 0;
}

/**
 * Extract usage values from a raw SDK `result` message for {@link ClaudeCodeSession.pushCumulativeUsage}.
 *
 * Returns `undefined` for any field that is absent or not a valid finite non-negative number,
 * so the caller can skip baseline updates for missing fields.
 *
 * Guards against `msg.usage` being `null` or a non-object at runtime despite the TypeScript cast.
 */
function parseUsageFromMessage(msg: Record<string, unknown>): {
  totalInput: number | undefined;
  totalOutput: number | undefined;
  totalCostMillicents: number | undefined;
} {
  const rawUsage = msg.usage;
  const usage = rawUsage !== null && typeof rawUsage === "object"
    ? rawUsage as Record<string, unknown>
    : undefined;

  const costUsd = msg.total_cost_usd;

  // Sum only the input-token fields that are actually present and finite.
  // If all are absent we pass undefined so the baseline is not reset to 0.
  const inputParts = [
    usage?.["input_tokens"],
    usage?.["cache_read_input_tokens"],
    usage?.["cache_creation_input_tokens"],
  ].filter((v): v is number => isFiniteNonNegative(v as number | undefined));
  const totalInput = inputParts.length > 0 ? inputParts.reduce((a, b) => a + b, 0) : undefined;

  const rawOutput = usage?.["output_tokens"];
  const totalOutput = isFiniteNonNegative(rawOutput as number | undefined) ? rawOutput as number : undefined;

  const totalCostMillicents = isFiniteNonNegative(costUsd as number | undefined)
    ? Math.round((costUsd as number) * 100_000)
    : undefined;

  return { totalInput, totalOutput, totalCostMillicents };
}

/** @internal Map a raw Claude Agent SDK message to Grackle AgentEvent(s). Exported for testing. */
export function mapMessage(msg: Record<string, unknown>): AgentEvent[] {
  const ts = new Date().toISOString();
  const type = msg.type as string | undefined;

  // SDK streaming format: { type: "assistant", message: { role, content: [...] } }
  if (type === "assistant") {
    const inner = msg.message as Record<string, unknown> | undefined;
    const content = inner?.content;
    if (Array.isArray(content)) {
      const events: AgentEvent[] = [];
      for (const block of content) {
        const b = block as Record<string, unknown>;
        if (b.type === "text") {
          events.push({ type: "text", timestamp: ts, content: b.text as string, raw: b });
        } else if (b.type === "tool_use") {
          events.push({
            type: "tool_use",
            timestamp: ts,
            content: JSON.stringify({ tool: b.name, args: b.input }),
            raw: b,
          });
        } else if (b.type === "tool_result") {
          events.push({
            type: "tool_result",
            timestamp: ts,
            content: typeof b.content === "string" ? b.content : JSON.stringify(b.content),
            raw: b,
          });
        }
      }
      return events;
    }
    return [];
  }

  if (type === "result") {
    // result messages are handled in consumeQuery() for error checking; skip here
    return [];
  }

  if (type === "system") {
    const subtype = msg.subtype as string | undefined;
    if (subtype === "init") {
      return [{ type: "system", timestamp: ts, content: `Session initialized (${msg.model ? String(msg.model) : "unknown model"})`, raw: msg }];
    }
    return [];
  }

  return [];
}

/** Built-in Claude Code tools that must be explicitly listed in allowedTools. */
const BUILTIN_TOOLS: string[] = [
  "Bash", "Read", "Write", "Edit", "Glob", "Grep",
  "WebSearch", "WebFetch", "Task", "Agent", "NotebookEdit",
];

/** Agent session backed by the Claude Agent SDK (`@anthropic-ai/claude-agent-sdk`). */
class ClaudeCodeSession extends BaseAgentSession {
  public runtimeName: string = "claude-code";
  protected readonly runtimeDisplayName: string = "Claude Code";
  protected readonly noMessagesError: string =
    "Claude Code returned no messages. Is ANTHROPIC_API_KEY set or ~/.claude/.credentials.json mounted?";

  /** The active AbortController for the current query(), used to cancel on kill. */
  private activeAbort?: AbortController;
  /** Cached SDK options built once during setupSdk(), reused for follow-up queries. */
  private cachedSdkOptions?: Record<string, unknown>;

  /**
   * IDs of tool_use blocks awaiting results. The Claude Agent SDK executes tools
   * internally and does not surface tool_result events. When the next assistant
   * message arrives (proving the tool completed), we emit synthetic tool_result
   * events so the frontend can pair them with the original tool_use.
   */
  private pendingToolUseIds: Map<string, string> = new Map();

  // ─── Cumulative usage tracking ──────────────────────────────
  // The Claude Agent SDK's `total_cost_usd` and `usage` fields on `result`
  // messages are **cumulative session totals**, not per-turn increments.
  // We track the last reported values and emit only the delta so the
  // server-side `updateSessionUsage` accumulator isn't double-counted.

  /** Last cumulative cost reported by the SDK (in millicents, for delta computation). */
  private lastReportedCostMillicents: number = 0;
  /** Last cumulative input token count reported by the SDK. */
  private lastReportedInputTokens: number = 0;
  /** Last cumulative output token count reported by the SDK. */
  private lastReportedOutputTokens: number = 0;

  // ─── Persistent process mode ────────────────────────────────
  // When active, ONE query() stays alive across multiple turns. The SDK's
  // AsyncIterable prompt mode keeps the process running and waiting for input.

  /** The persistent Query object (AsyncGenerator). Null when using resume-per-input. */
  private persistentQuery?: AsyncIterable<Record<string, unknown>> & { close?: () => void };
  /** Input queue fed to the SDK as AsyncIterable prompt — pushing yields new turns. */
  private promptQueue?: AsyncQueue<Record<string, unknown>>;
  /** Resolves when the current turn's response is complete (result message received). */
  private turnCompleteResolve?: () => void;

  /** System context is injected via sdkOptions.systemPrompt, not prepended to the prompt. */
  protected override buildInitialPrompt(): string {
    return this.prompt;
  }

  /**
   * Convert cumulative SDK usage values to incremental deltas and emit a usage event.
   *
   * The Claude Agent SDK's `total_cost_usd` and `usage` fields on `result` messages
   * are **cumulative session totals** (running sums across all turns), not per-turn
   * increments. Passing them directly to {@link pushUsageEvent} would cause the
   * server-side `updateSessionUsage` accumulator to double-count costs — O(n²)
   * inflation for n turns.
   *
   * This mirrors the cumulative→delta conversion performed by the ACP runtime.
   *
   * Each field is optional: pass `undefined` when the SDK message does not include
   * that value. This prevents a missing field from resetting the tracking baseline
   * to zero and inflating subsequent deltas.
   */
  private pushCumulativeUsage(
    totalInputTokens: number | undefined,
    totalOutputTokens: number | undefined,
    totalCostMillicents: number | undefined,
  ): void {
    // Normalize: treat non-finite or negative values as absent so that a bad
    // SDK message cannot poison the lastReported* baseline with NaN/Infinity.
    const safeInput = isFiniteNonNegative(totalInputTokens) ? totalInputTokens : undefined;
    const safeOutput = isFiniteNonNegative(totalOutputTokens) ? totalOutputTokens : undefined;
    const safeCost = isFiniteNonNegative(totalCostMillicents) ? totalCostMillicents : undefined;

    // Only compute and update baseline for fields that are actually present.
    const deltaInput = safeInput !== undefined ? safeInput - this.lastReportedInputTokens : 0;
    const deltaOutput = safeOutput !== undefined ? safeOutput - this.lastReportedOutputTokens : 0;
    const deltaCost = safeCost !== undefined ? safeCost - this.lastReportedCostMillicents : 0;

    if (safeInput !== undefined) { this.lastReportedInputTokens = safeInput; }
    if (safeOutput !== undefined) { this.lastReportedOutputTokens = safeOutput; }
    if (safeCost !== undefined) { this.lastReportedCostMillicents = safeCost; }

    // Guard against SDK reporting lower values (e.g. session reset edge cases)
    if (deltaInput <= 0 && deltaOutput <= 0 && deltaCost <= 0) {
      return;
    }
    this.pushUsageEvent(
      Math.max(0, deltaInput),
      Math.max(0, deltaOutput),
      Math.max(0, deltaCost),
    );
  }

  // ─── BaseAgentSession hooks ──────────────────────────────

  protected async setupSdk(): Promise<void> {
    // Determine cwd: worktree > /workspace > default
    const cwd = await this.resolveWorkDir(true);

    // SDK query() expects { prompt, options: { model, mcpServers, ... } }
    // Both permissionMode AND allowDangerouslySkipPermissions are needed for full bypass.
    // Built-in tools must also be in allowedTools explicitly — the SDK treats allowedTools
    // as a whitelist that overrides permissionMode for tool-level checks.
    const sdkOptions: Record<string, unknown> = {
      model: this.model,
      permissionMode: "bypassPermissions",
      allowDangerouslySkipPermissions: true,
      allowedTools: [...BUILTIN_TOOLS],
      settingSources: ["project"],
      ...(cwd ? { cwd } : {}),
    };

    // Load MCP server config from env var or SpawnOptions.
    // resolveMcpServers() injects the Grackle MCP server entry. When broker config is
    // available, it uses HTTP (url + headers). The Claude Agent SDK supports both stdio
    // (command/args) and HTTP (url/headers) MCP server configs.
    const mcpConfig = this.resolveMcp();
    if (mcpConfig.servers) {
      sdkOptions.mcpServers = mcpConfig.servers;
    }
    if (mcpConfig.disallowedTools.length > 0) {
      sdkOptions.disallowedTools = mcpConfig.disallowedTools;
    }

    // Add MCP tool patterns to allowedTools
    if (sdkOptions.mcpServers) {
      const mcpServerNames = Object.keys(sdkOptions.mcpServers as Record<string, unknown>);
      const mcpTools = mcpServerNames.map(name => `mcp__${name}__*`);
      (sdkOptions.allowedTools as string[]).push(...mcpTools);
    }

    // Inject system context via SDK-native systemPrompt (appended to Claude Code's built-in prompt)
    if (this.systemContext) {
      sdkOptions.systemPrompt = { preset: "claude_code" as const, append: this.systemContext };
    }

    if (this.maxTurns > 0) {
      sdkOptions.maxTurns = this.maxTurns;
    }

    // Pass through caller-provided hooks (e.g. Stop hooks for PR readiness).
    // Consumers supply their own hooks via SpawnOptions; the runtime does not
    // bundle any platform-specific hook implementations.
    if (this.hooks) {
      sdkOptions.hooks = this.hooks;
    }

    // Ensure the SDK session storage directory is writable so that multi-turn
    // conversations can be resumed. If ~/.claude is read-only (common in Docker
    // with bind-mounted host config), redirect session writes via CLAUDE_CONFIG_DIR.
    const configDir = process.env.CLAUDE_CONFIG_DIR || join(homedir(), ".claude");
    const projectsDir = join(configDir, "projects");
    // Attempt to create the projects directory if it does not exist yet so
    // that a missing dir (ENOENT) is not mistaken for a read-only filesystem.
    try { mkdirSync(projectsDir, { recursive: true }); } catch { /* handled below */ }
    if (!isDirectoryWritable(projectsDir)) {
      const fallbackRoot = join(tmpdir(), ".claude-sdk");
      const fallbackProjects = join(fallbackRoot, "projects");
      try {
        mkdirSync(fallbackProjects, { recursive: true });
        if (isDirectoryWritable(fallbackProjects)) {
          // Copy credential/config files so the SDK can authenticate from
          // the fallback directory (it reads .credentials.json from CLAUDE_CONFIG_DIR).
          for (const file of [".credentials.json", "settings.json", "settings.local.json"]) {
            try {
              copyFileSync(join(configDir, file), join(fallbackRoot, file));
              if (file === ".credentials.json") {
                chmodSync(join(fallbackRoot, file), 0o600);
              }
            } catch { /* missing is fine */ }
          }
          sdkOptions.env = { ...process.env, CLAUDE_CONFIG_DIR: fallbackRoot };
          logger.warn(
            { configDir, fallback: fallbackRoot },
            "Claude config directory is read-only — redirecting session writes to writable fallback",
          );
        } else {
          logger.warn(
            { configDir },
            "Claude config directory is read-only and no writable fallback available — multi-turn conversations will fail",
          );
        }
      } catch (err) {
        logger.warn(
          { configDir, err },
          "Failed to create writable fallback for Claude config — multi-turn conversations will fail",
        );
      }
    }

    this.cachedSdkOptions = sdkOptions;
  }

  protected async runInitialQuery(prompt: string): Promise<number> {
    if (!prompt) {
      // No initial prompt (e.g. System task) — skip the query and wait for
      // the first user message, which will be handled as an initial query
      // in executeFollowUp(). Return 1 so the base class does not emit the
      // misleading noMessagesError.
      return 1;
    }
    return this.startPersistentQuery(prompt);
  }

  protected async executeFollowUp(text: string): Promise<void> {
    if (this.promptQueue) {
      // Persistent mode: push into live AsyncIterable prompt queue
      await this.sendToPersistentQuery(text);
    } else if (this.runtimeSessionId) {
      // Resume-per-input fallback (resumed sessions, or persistent query failed to start)
      const resumeOptions = { ...this.cachedSdkOptions!, resume: this.runtimeSessionId };
      await this.consumeQuery(text, resumeOptions);
    } else {
      // No prior session (first message after empty-prompt start) — start persistent query
      await this.startPersistentQuery(text);
    }
  }

  protected abortActive(): void {
    if (this.activeAbort) {
      this.activeAbort.abort();
    }
    // Close the prompt queue (ends the AsyncIterable → SDK exits)
    if (this.promptQueue) {
      this.promptQueue.close();
    }
    // Close the persistent query if it has a close method
    const pq = this.persistentQuery as { close?: () => void } | undefined;
    if (pq?.close) {
      pq.close();
    }
  }

  protected override releaseResources(): void {
    this.persistentQuery = undefined;
    this.promptQueue = undefined;
    this.turnCompleteResolve = undefined;
  }

  // ─── Persistent process mode ─────────────────────────────

  /**
   * Start a persistent query using AsyncIterable prompt mode. The SDK process
   * stays alive across multiple turns. Follow-up messages are sent via
   * the Query's streamInput() method.
   */
  private async startPersistentQuery(initialPrompt: string): Promise<number> {
    const query: QueryFn = await getQuery();
    const abort = new AbortController();
    this.activeAbort = abort;

    try {
      // Create a prompt queue — the SDK reads from this AsyncIterable and keeps
      // the process alive waiting for the next yield. Push messages to deliver them.
      this.promptQueue = new AsyncQueue<Record<string, unknown>>();

      // Push the initial user message
      this.promptQueue.push({
        type: "user",
        message: { role: "user", content: [{ type: "text", text: initialPrompt }] },
      });

      // Start query with AsyncIterable prompt — process stays alive
      const queryInput: Record<string, unknown> = {
        prompt: this.promptQueue,
        options: { ...this.cachedSdkOptions!, abortController: abort },
      };
      const conversation = query(queryInput) as unknown as AsyncIterable<Record<string, unknown>> & {
        close?: () => void;
      };

      this.persistentQuery = conversation;

      // Start background consumer that reads ALL messages from the persistent query
      // and pushes them to the event queue. This runs for the lifetime of the query.
      this.consumePersistentStream(conversation).catch((err) => {
        if (!this.killed) {
          logger.warn({ err }, "Persistent query stream ended unexpectedly");
        }
        // Unblock any pending waitForTurnComplete so the input loop can recover
        this.turnCompleteResolve?.();
        // Clear persistent state so follow-ups fall back to resume-per-input
        this.promptQueue = undefined;
        this.persistentQuery = undefined;
      });

      // Wait for the first turn to complete (result message)
      return this.waitForTurnComplete();
    } catch (err) {
      // Persistent mode failed — fall back to resume-per-input
      logger.warn({ err }, "Persistent process mode failed — falling back to resume-per-input");
      this.promptQueue = undefined;
      this.persistentQuery = undefined;
      return this.consumeQuery(initialPrompt, this.cachedSdkOptions!);
    }
  }

  /**
   * Send a follow-up message to the persistent query by pushing into the prompt queue.
   * The SDK process picks it up immediately (no restart). Blocks until the turn completes.
   */
  private async sendToPersistentQuery(text: string): Promise<void> {
    if (!this.promptQueue) {
      throw new Error("No active prompt queue for persistent query");
    }

    // Push user message into the prompt queue — SDK reads it from the AsyncIterable
    this.promptQueue.push({
      type: "user",
      message: { role: "user", content: [{ type: "text", text }] },
    });

    // Wait for this turn's response to complete
    await this.waitForTurnComplete();
  }

  /**
   * Background consumer for the persistent query stream. Reads messages
   * and pushes events to the queue for the lifetime of the query.
   * Signals turn completion via turnCompleteResolve when a result message arrives.
   */
  private async consumePersistentStream(
    conversation: AsyncIterable<Record<string, unknown>>,
  ): Promise<void> {
    const ts: () => string = () => new Date().toISOString();

    for await (const msg of conversation) {
      if (this.killed) {
        break;
      }

      // Extract session ID from system init message
      if (msg.type === "system" && msg.session_id) {
        this.setRuntimeSessionId(msg.session_id as string);
      }

      // Check for result errors
      if (msg.type === "result" && msg.is_error) {
        const errorMsg = (msg.result as string) || "Claude Code returned an error";
        this.eventQueue.push({ type: "error", timestamp: ts(), content: errorMsg, raw: msg });
      }

      // Extract usage data from successful result messages
      if (msg.type === "result" && !msg.is_error) {
        const { totalInput, totalOutput, totalCostMillicents } = parseUsageFromMessage(msg);
        if (totalInput !== undefined || totalOutput !== undefined || totalCostMillicents !== undefined) {
          this.pushCumulativeUsage(totalInput, totalOutput, totalCostMillicents);
        }
      }

      // Map SDK messages to Grackle events
      const events = mapMessage(msg);
      this.emitWithSyntheticToolResults(events, ts);

      // Signal turn completion on result message. Flush pending tool_use IDs
      // since mapMessage(result) returns [] — emitWithSyntheticToolResults won't
      // see non-tool_use events to trigger a flush.
      if (msg.type === "result") {
        this.flushPendingToolResults(ts);
        this.turnCompleteResolve?.();
      }
    }

    // Flush any remaining pending tool_use IDs (tool completed but no follow-up message)
    this.flushPendingToolResults(ts);

    // Stream ended — resolve any pending turn wait and clear persistent state
    // so follow-ups fall back to resume-per-input instead of trying to push
    // into a dead prompt queue.
    this.turnCompleteResolve?.();
    this.promptQueue = undefined;
    this.persistentQuery = undefined;
  }

  /** Wait for the current turn to complete (result message received from SDK). */
  private waitForTurnComplete(): Promise<number> {
    return new Promise<number>((resolve) => {
      this.turnCompleteResolve = () => {
        this.turnCompleteResolve = undefined;
        resolve(1);
      };
    });
  }

  // ─── Synthetic tool_result emission ─────────────────────

  /**
   * Emit events to the queue, synthesizing tool_result events for pending tool_use IDs.
   *
   * The Claude Agent SDK executes tools internally and only streams assistant messages.
   * Tool results are never surfaced as events. When we see a new batch of events that
   * contains non-tool_use events (text, usage, etc.), we know all pending tools have
   * completed — so we emit synthetic tool_result events before the new events.
   * This enables the frontend to pair tool_use/tool_result by ID.
   */
  private emitWithSyntheticToolResults(events: AgentEvent[], ts: () => string): void {
    // If there are pending tool_use IDs and this batch contains non-tool_use events,
    // the tools have completed — emit synthetic tool_result events first.
    if (this.pendingToolUseIds.size > 0) {
      const hasCompletion = events.some((e) => e.type !== "tool_use");
      if (hasCompletion) {
        // Remove any pending IDs that have real tool_result events in this batch
        // (the SDK may include tool_result blocks in some modes).
        for (const event of events) {
          if (event.type === "tool_result") {
            const raw = event.raw as Record<string, unknown> | undefined;
            if (raw && typeof raw.tool_use_id === "string") {
              this.pendingToolUseIds.delete(raw.tool_use_id);
            }
          }
        }
        this.flushPendingToolResults(ts);
      }
    }

    // Push all events, tracking new tool_use IDs
    for (const event of events) {
      if (event.type === "tool_use") {
        const raw = event.raw as Record<string, unknown> | undefined;
        if (raw && typeof raw.id === "string") {
          this.pendingToolUseIds.set(raw.id, raw.name as string || "");
        }
      }
      this.eventQueue.push(event);
    }
  }

  /** Emit synthetic tool_result events for all pending tool_use IDs and clear the map. */
  private flushPendingToolResults(ts: () => string): void {
    for (const [id] of this.pendingToolUseIds) {
      this.eventQueue.push({
        type: "tool_result",
        timestamp: ts(),
        content: "",
        raw: { tool_use_id: id, is_error: false, synthetic: true },
      });
    }
    this.pendingToolUseIds.clear();
  }

  // ─── Resume-per-input fallback ──────────────────────────

  /**
   * Consume all messages from a query() conversation, pushing events to the queue.
   * Returns the number of meaningful messages processed.
   * Used as fallback when persistent mode is not available (resume-per-input).
   */
  private async consumeQuery(prompt: string, sdkOptions: Record<string, unknown>): Promise<number> {
    const query: QueryFn = await getQuery();
    const ts: () => string = () => new Date().toISOString();

    // Reset cumulative usage baseline for this query() call. In resume-per-input
    // mode, each query() call reports usage cumulative within that call only (not
    // the session total), so the baseline must start at zero for each invocation.
    this.lastReportedCostMillicents = 0;
    this.lastReportedInputTokens = 0;
    this.lastReportedOutputTokens = 0;

    // Each query gets its own AbortController
    const abort = new AbortController();
    this.activeAbort = abort;

    const queryInput: Record<string, unknown> = {
      prompt,
      options: { ...sdkOptions, abortController: abort },
    };
    const conversation = query(queryInput) as unknown as AsyncIterable<Record<string, unknown>>;
    let messageCount = 0;

    for await (const msg of conversation) {
      if (this.killed) {
        break;
      }

      // Extract session ID from system init message
      if (msg.type === "system" && msg.session_id) {
        this.setRuntimeSessionId(msg.session_id as string);
      }

      // Check for result errors (e.g. invalid API key)
      if (msg.type === "result" && msg.is_error) {
        this.flushPendingToolResults(ts);
        const errorMsg = (msg.result as string) || "Claude Code returned an error";
        this.eventQueue.push({ type: "error", timestamp: ts(), content: errorMsg, raw: msg });
        continue;
      }

      // Extract usage data from successful result messages
      if (msg.type === "result" && !msg.is_error) {
        this.flushPendingToolResults(ts);
        const { totalInput, totalOutput, totalCostMillicents } = parseUsageFromMessage(msg);
        if (totalInput !== undefined || totalOutput !== undefined || totalCostMillicents !== undefined) {
          this.pushCumulativeUsage(totalInput, totalOutput, totalCostMillicents);
        }
      }

      const events = mapMessage(msg);
      messageCount += events.length;
      this.emitWithSyntheticToolResults(events, ts);
    }

    // Flush any remaining pending tool_use IDs
    this.flushPendingToolResults(ts);
    return messageCount;
  }
}

/** Check if a directory exists and is writable (with execute permission to create entries). */
function isDirectoryWritable(dir: string): boolean {
  try {
    // eslint-disable-next-line no-bitwise -- fs constants are bitmask flags
    accessSync(dir, fsConstants.W_OK | fsConstants.X_OK);
    return true;
  } catch {
    return false;
  }
}

/** Runtime that delegates to the Claude Code SDK (`@anthropic-ai/claude-agent-sdk`). */
export class ClaudeCodeRuntime extends BaseAgentRuntime {
  public name: string = "claude-code";

  protected createSession(opts: CreateSessionOptions): AgentSession {
    return new ClaudeCodeSession(opts);
  }
}
