/**
 * Core SDK client wrapper for Forge.
 * Creates configured query() instances with agents, MCP, hooks, and permissions.
 *
 * Key requirements:
 * 1. Start env with full process.env (HOME, PATH, etc.)
 * 2. Ensure HOME is set so SDK can find ~/.claude.json OAuth
 * 3. Expand PATH to include fnm/nvm/volta bin dirs
 * 4. Remove CLAUDECODE env var to prevent "nested session" detection
 * 5. Pass settingSources for SDK settings; override to ['project'] in confirm mode
 * 6. Find claude CLI binary via candidate paths + version manager scans
 */

import { query } from '@anthropic-ai/claude-agent-sdk'
import type { Query, CanUseTool, Options, McpServerConfig, SDKUserMessage, ThinkingConfig } from '@anthropic-ai/claude-agent-sdk'
import type { EffortLevel } from '@anthropic-ai/claude-agent-sdk'
import type { ContentBlockParam } from '@anthropic-ai/sdk/resources'
import { execFileSync, execSync } from 'child_process'
import fs from 'fs'
import path from 'path'
import os from 'os'
import { getDb } from '@/lib/db'
import { resolveProvider } from '@/lib/provider'
import { providerModelRepo } from '@/lib/database/repositories/provider-model.repo'
import type { ModelCapabilities } from '@/lib/engine/model-registry/types'
import { loadWorkspaceContext, loadMemoryContext, loadRulesContext, getProjectPath } from '@/lib/workspace-fs'
import { loadAgentsFromFiles } from '@/lib/sdk/agents-loader'
import { FORGE_BASE_SYSTEM_PROMPT, FORGE_IM_SYSTEM_PROMPT, buildEnvironmentPrompt, FEISHU_LARK_CLI_GUIDE, WECOM_GUIDE, DINGTALK_GUIDE } from './system-prompt'
import { loadMcpServersFromDb } from '@/lib/sdk/mcp-loader'
import { createBrowserMcpServer } from '@/lib/browser-tools'
import { getForgeDataDir } from '@/lib/forge-data'

// ── Claude CLI Binary Discovery ─────────────────────────────────

let _cachedClaudePath: string | null = null
let _cachedClaudePathTime = 0

function findClaudeExecutable(): string | undefined {
  const now = Date.now()
  if (_cachedClaudePath && now - _cachedClaudePathTime < 60_000) {
    return _cachedClaudePath
  }
  const found = _findClaudeUncached()
  if (found) {
    _cachedClaudePath = found
    _cachedClaudePathTime = now
  }
  return found
}

function _findClaudeUncached(): string | undefined {
  const home = os.homedir()

  // 1. Check well-known fixed paths
  const candidates = [
    '/usr/local/bin/claude',
    '/opt/homebrew/bin/claude',
    path.join(home, '.npm-global', 'bin', 'claude'),
    path.join(home, '.local', 'bin', 'claude'),
    path.join(home, '.claude', 'bin', 'claude'),
  ]
  for (const p of candidates) {
    if (fs.existsSync(p)) {
      try {
        execFileSync(p, ['--version'], { timeout: 5000, stdio: 'pipe' })
        return p
      } catch { /* not executable, try next */ }
    }
  }

  // 2. Scan fnm node versions for globally installed claude-code
  const fnmVersionsDir = path.join(home, '.fnm', 'node-versions')
  const cliRelPath = 'installation/lib/node_modules/@anthropic-ai/claude-code/cli.js'
  try {
    if (fs.existsSync(fnmVersionsDir)) {
      for (const v of fs.readdirSync(fnmVersionsDir)) {
        const cliPath = path.join(fnmVersionsDir, v, cliRelPath)
        if (fs.existsSync(cliPath)) return cliPath
      }
    }
  } catch { /* skip */ }

  // 3. Scan nvm node versions
  const nvmDir = process.env.NVM_DIR || path.join(home, '.nvm')
  const nvmVersionsDir = path.join(nvmDir, 'versions', 'node')
  try {
    if (fs.existsSync(nvmVersionsDir)) {
      for (const v of fs.readdirSync(nvmVersionsDir)) {
        const cliPath = path.join(nvmVersionsDir, v, 'lib', 'node_modules', '@anthropic-ai', 'claude-code', 'cli.js')
        if (fs.existsSync(cliPath)) return cliPath
      }
    }
  } catch { /* skip */ }

  // 4. Scan volta
  const voltaToolsDir = path.join(home, '.volta', 'tools', 'image', 'packages', '@anthropic-ai', 'claude-code')
  try {
    if (fs.existsSync(voltaToolsDir)) {
      for (const v of fs.readdirSync(voltaToolsDir)) {
        const cliPath = path.join(voltaToolsDir, v, 'cli.js')
        if (fs.existsSync(cliPath)) return cliPath
      }
    }
  } catch { /* skip */ }

  // 5. Last resort: ask user's login shell (handles any custom PATH setup)
  try {
    const shell = process.env.SHELL || '/bin/zsh'
    const result = execSync(`${shell} -ilc "which claude" 2>/dev/null`, {
      timeout: 5000,
      stdio: 'pipe',
    })
    const found = result.toString().trim()
    if (found && fs.existsSync(found)) {
      return fs.realpathSync(found)
    }
  } catch { /* not found */ }

  return undefined
}

// ── Expanded PATH builder ───────────────────────────────────────

function getExpandedPath(): string {
  const home = os.homedir()
  const parts = new Set((process.env.PATH || '').split(path.delimiter).filter(Boolean))

  // Add common Node.js version manager bin directories
  const extras = [
    '/usr/local/bin',
    '/opt/homebrew/bin',
    path.join(home, '.local', 'bin'),
    path.join(home, '.npm-global', 'bin'),
  ]

  // fnm: add all version bin dirs
  const fnmVersionsDir = path.join(home, '.fnm', 'node-versions')
  try {
    if (fs.existsSync(fnmVersionsDir)) {
      for (const v of fs.readdirSync(fnmVersionsDir)) {
        extras.push(path.join(fnmVersionsDir, v, 'installation', 'bin'))
      }
    }
  } catch { /* skip */ }

  // nvm: add all version bin dirs
  const nvmDir = process.env.NVM_DIR || path.join(home, '.nvm')
  const nvmVersionsDir = path.join(nvmDir, 'versions', 'node')
  try {
    if (fs.existsSync(nvmVersionsDir)) {
      for (const v of fs.readdirSync(nvmVersionsDir)) {
        extras.push(path.join(nvmVersionsDir, v, 'bin'))
      }
    }
  } catch { /* skip */ }

  // volta
  extras.push(path.join(home, '.volta', 'bin'))

  for (const p of extras) {
    if (p) parts.add(p)
  }
  return [...parts].join(path.delimiter)
}

// ── SDK Environment Builder ─────────────────────────────────────

/**
 * Build the environment for the SDK subprocess.
 *
 * 1. Start with full process.env
 * 2. Ensure HOME is set
 * 3. Expand PATH to include version managers
 * 4. Remove CLAUDECODE to prevent nested session detection
 * 5. Inject API credentials with correct auth header type
 */
function buildSdkEnv(apiKey: string, baseUrl?: string, authType: 'api_key' | 'auth_token' = 'api_key'): Record<string, string> {
  // Copy all of process.env, keeping only string values.
  const sdkEnv: Record<string, string> = Object.fromEntries(
    Object.entries(process.env).filter(([, v]) => typeof v === 'string')
  ) as Record<string, string>

  // Ensure HOME is set so SDK can find ~/.claude.json
  if (!sdkEnv.HOME) sdkEnv.HOME = os.homedir()

  // Expand PATH to include fnm/nvm/volta bin directories
  sdkEnv.PATH = getExpandedPath()

  // Remove CLAUDECODE env var to prevent "nested session" detection.
  // When Forge is launched from within a Claude Code CLI session,
  // the child process inherits this variable and the SDK refuses to start.
  delete sdkEnv.CLAUDECODE

  // Inject or clean API credentials based on auth type
  if (apiKey) {
    if (authType === 'auth_token') {
      // Non-Anthropic providers: use Bearer token (Authorization header)
      sdkEnv.ANTHROPIC_AUTH_TOKEN = apiKey
      // Must NOT set ANTHROPIC_API_KEY — it would send the wrong header
      delete sdkEnv.ANTHROPIC_API_KEY
    } else {
      // Anthropic native: use x-api-key header
      sdkEnv.ANTHROPIC_API_KEY = apiKey
      sdkEnv.ANTHROPIC_AUTH_TOKEN = apiKey
    }
  } else {
    // CLI auth mode: remove any stale API keys so SDK auto-detects OAuth from ~/.claude.json
    delete sdkEnv.ANTHROPIC_API_KEY
    delete sdkEnv.ANTHROPIC_AUTH_TOKEN
  }

  if (baseUrl) {
    sdkEnv.ANTHROPIC_BASE_URL = baseUrl
  }

  return sdkEnv
}

// ── Query Options Builder ───────────────────────────────────────

export interface ForgeQueryOptions {
  prompt: string
  sessionId: string
  model: string
  workspaceId: string
  userId?: string
  abortController?: AbortController
  canUseTool?: CanUseTool
  bypassPermissions?: boolean
  customSystemPrompt?: string
  resumeSession?: boolean
  extraMcpServers?: Record<string, McpServerConfig>
  /** Unified thinking mode: 'off' | 'auto' | 'max' */
  thinkingMode?: string
  /** Provider type for thinking mode mapping */
  providerType?: string
  /** Skip MCP servers (DB + browser) for lightweight queries like IM */
  skipMcpServers?: boolean
  /** Skip loading agents from files */
  skipAgents?: boolean
  /** Use compact IM system prompt instead of full Forge prompt */
  useImPrompt?: boolean
  /** Skip session persistence (useful when not planning to resume) */
  skipPersistSession?: boolean
  /** File attachments for multimodal content (images, PDFs, text files) */
  attachments?: ForgeAttachment[]
  /** Feishu per-user credentials — injected into the SDK subprocess via the native lark-cli env contract */
  feishuAuth?: ForgeFeishuAuth
  /** WeCom CLI config directory — injected into the SDK subprocess as WECOM_CLI_CONFIG_DIR */
  wecomCliConfigDir?: string
  /** DingTalk app credentials — injected into the SDK subprocess as DWS_CLIENT_ID / DWS_CLIENT_SECRET */
  dingtalkAuth?: ForgeDingtalkAuth
  /** Whether this query originates from a Feishu IM channel (injects Feishu tool rules into system prompt) */
  isFeishuChannel?: boolean
  /** Whether this query originates from a WeCom IM channel (injects WeCom tool rules into system prompt) */
  isWeComChannel?: boolean
  /** Whether this query originates from a DingTalk IM channel (injects DingTalk tool rules into system prompt) */
  isDingtalkChannel?: boolean
}

/**
 * Feishu per-user credentials for the SDK subprocess.
 * Mapped onto lark-cli's native LARKSUITE_CLI_* environment variables.
 */
export interface ForgeFeishuAuth {
  /** Feishu app ID (lark-cli requires it alongside the user token) */
  appId: string
  /** User access_token obtained via Forge-managed OAuth Device Flow */
  token: string
  /** Bot platform: 'feishu' (CN) or 'lark' (global) — maps to LARKSUITE_CLI_BRAND */
  brand: 'feishu' | 'lark'
}

/**
 * DingTalk app credentials for the SDK subprocess.
 * Mapped onto dws CLI's native DWS_CLIENT_ID / DWS_CLIENT_SECRET environment variables.
 */
export interface ForgeDingtalkAuth {
  /** DingTalk app Client ID */
  clientId: string
  /** DingTalk app Client Secret */
  clientSecret: string
}

export interface ForgeAttachment {
  /** Original filename */
  name: string
  /** Absolute server path to the uploaded file */
  serverPath: string
  /** MIME type */
  mimeType: string
  /**
   * Tier classification:
   * - 'image': base64 vision block (Claude native) + disk path fallback
   * - 'file': non-image attachment — Agent uses Read tool via disk path
   */
  tier: 'image' | 'file'
}

function buildSystemPrompt(workspaceId: string, userId?: string): string {
  const workspaceContext = loadWorkspaceContext(workspaceId, userId)
  const memoryContext = loadMemoryContext(workspaceId, userId)
  const rulesContext = loadRulesContext(workspaceId, userId)
  const cwd = (() => { try { return getProjectPath(workspaceId, userId) } catch { return process.cwd() } })()

  // Five-layer system prompt architecture (aligned with Claude Code):
  // 1. Forge base prompt (always present — tool usage, memory, code quality, git, safety)
  // 2. Environment info (dynamic — platform, cwd, shell)
  // 3. Forge extra config files (SOUL.md, IDENTITY.md, USER.md — CLAUDE.md is loaded by SDK natively)
  // 4. Memory context (MEMORY.md first 200 lines + recent daily memories)
  // 5. Rules context (.claude/rules/*.md — unconditional rules loaded, path-scoped listed)
  const systemParts: string[] = [
    FORGE_BASE_SYSTEM_PROMPT,
    buildEnvironmentPrompt(cwd),
  ]
  if (workspaceContext) systemParts.push(workspaceContext)
  if (memoryContext) systemParts.push(memoryContext)
  if (rulesContext) systemParts.push(rulesContext)

  return systemParts.join('\n\n---\n\n')
}

/**
 * Build system prompt for IM queries.
 * Uses compact base prompt (saves ~2500 tokens vs full) but still loads
 * workspace context (SOUL.md, IDENTITY.md, USER.md, etc.) and memory
 * context (MEMORY.md + recent daily memories) so IM Agent has the same
 * personality, user info, and memory as the desktop Agent.
 */
function buildImSystemPrompt(workspaceId: string, userId?: string): string {
  const workspaceContext = loadWorkspaceContext(workspaceId, userId)
  const memoryContext = loadMemoryContext(workspaceId, userId)
  const cwd = (() => { try { return getProjectPath(workspaceId, userId) } catch { return process.cwd() } })()

  const systemParts: string[] = [
    FORGE_IM_SYSTEM_PROMPT,
    buildEnvironmentPrompt(cwd),
  ]
  if (workspaceContext) systemParts.push(workspaceContext)
  if (memoryContext) systemParts.push(memoryContext)

  return systemParts.join('\n\n---\n\n')
}

/**
 * Create a configured SDK query instance.
 */
export function createForgeQuery(opts: ForgeQueryOptions): Query {
  // Resolve API key (supports both API key and CLI auth modes)
  const resolved = resolveProvider(opts.model, opts.userId)

  // Build system prompt
  // IM queries use a compact base prompt but still load workspace context + memory
  // to maintain consistent personality, user info, and memory across desktop and IM
  const systemPrompt = opts.customSystemPrompt
    || (opts.useImPrompt ? buildImSystemPrompt(opts.workspaceId, opts.userId) : buildSystemPrompt(opts.workspaceId, opts.userId))

  // Prepend channel-specific tool-routing rules for Feishu or WeCom IM channels.
  let resolvedSystemPrompt = systemPrompt
  if (opts.isFeishuChannel) {
    resolvedSystemPrompt = `${FEISHU_LARK_CLI_GUIDE}\n\n---\n\n${resolvedSystemPrompt}`
  }
  if (opts.isWeComChannel) {
    resolvedSystemPrompt = `${WECOM_GUIDE}\n\n---\n\n${resolvedSystemPrompt}`
  }
  if (opts.isDingtalkChannel) {
    resolvedSystemPrompt = `${DINGTALK_GUIDE}\n\n---\n\n${resolvedSystemPrompt}`
  }

  // Debug: log system prompt composition and write full prompt to disk
  logSystemPrompt(opts.workspaceId, opts.userId, systemPrompt, resolvedSystemPrompt, opts)

  // Load agents from .claude/agents/*.md files (skip for IM — not needed)
  const agents = opts.skipAgents ? {} : loadAgentsFromFiles(opts.workspaceId, opts.userId)

  // Load MCP servers from DB + browser MCP + extras
  // Skip for IM queries (skipMcpServers) to reduce subprocess startup overhead
  let mcpServers: Record<string, McpServerConfig> | undefined
  if (!opts.skipMcpServers) {
    const dbMcpServers = loadMcpServersFromDb(opts.userId)
    const browserMcp = createBrowserMcpServer()
    mcpServers = {
      ...dbMcpServers,
      '__forge_browser': browserMcp,
      ...opts.extraMcpServers,
    }
  } else if (opts.extraMcpServers && Object.keys(opts.extraMcpServers).length > 0) {
    mcpServers = { ...opts.extraMcpServers }
  }

  // Build subprocess environment
  const sdkEnv = buildSdkEnv(resolved.apiKey, resolved.baseUrl, resolved.authType)

  // Inject Feishu user credentials for the native lark-cli (if provided).
  // lark-cli supports the LARKSUITE_CLI_* env contract natively and enters
  // "external provider" mode: API calls carry the injected user token and
  // interactive auth commands (auth login/logout) are rejected by the CLI
  // itself. APP_SECRET is deliberately NOT injected so it never enters the
  // agent subprocess environment.
  if (opts.feishuAuth) {
    sdkEnv.LARKSUITE_CLI_APP_ID = opts.feishuAuth.appId
    sdkEnv.LARKSUITE_CLI_USER_ACCESS_TOKEN = opts.feishuAuth.token
    sdkEnv.LARKSUITE_CLI_BRAND = opts.feishuAuth.brand
    // Isolated config dir: prevents fallback to the host user's personal
    // lark-cli config/keychain and keeps Forge from polluting it.
    const larkConfigDir = path.join(getForgeDataDir(), 'lark-cli')
    try { fs.mkdirSync(larkConfigDir, { recursive: true }) } catch { /* best effort */ }
    sdkEnv.LARKSUITE_CLI_CONFIG_DIR = larkConfigDir
  }

  // Inject DingTalk app credentials for the dws CLI (if provided).
  // dws CLI natively supports the DWS_CLIENT_ID / DWS_CLIENT_SECRET env contract
  // and automatically authenticates API calls using these credentials.
  if (opts.dingtalkAuth) {
    sdkEnv.DWS_CLIENT_ID = opts.dingtalkAuth.clientId
    sdkEnv.DWS_CLIENT_SECRET = opts.dingtalkAuth.clientSecret
    sdkEnv.DWS_CHANNEL = 'forge'
    sdkEnv.DINGTALK_AGENT = 'FORGE'
  }

  // Inject WeCom CLI config directory (if provided).
  // wecom-cli reads WECOM_CLI_CONFIG_DIR to locate its encrypted credentials
  // (bot.enc, .encryption_key, mcp_config.enc) for subprocess commands.
  if (opts.wecomCliConfigDir) {
    try { fs.mkdirSync(opts.wecomCliConfigDir, { recursive: true }) } catch { /* best effort */ }
    sdkEnv.WECOM_CLI_CONFIG_DIR = opts.wecomCliConfigDir
  }

  // Find Claude CLI executable
  const claudePath = findClaudeExecutable()

  // Resolve model capabilities for thinking/effort mapping
  const capabilities = getModelCapabilities(opts.model, opts.userId)

  // Build SDK options
  // NOTE: sessionId and resume are mutually exclusive in the SDK.
  // First message: set sessionId to persist the session.
  // Subsequent messages: set resume (without sessionId) to continue the conversation.
  const sdkOptions: Options = {
    model: opts.model,
    cwd: (() => { try { return getProjectPath(opts.workspaceId, opts.userId) } catch { return process.cwd() } })(),
    systemPrompt: resolvedSystemPrompt,
    env: sdkEnv,
    ...(opts.resumeSession
      ? { resume: opts.sessionId }
      : { sessionId: opts.sessionId, persistSession: !opts.skipPersistSession }),
    includePartialMessages: true,
    agentProgressSummaries: true,
    betas: ['context-1m-2025-08-07'],
    // settingSources: load settings from ~/.claude/, <project>/.claude/, and ~/.claude/settings.local.json.
    // Auth is NOT loaded from these — API key is via env vars, OAuth via subprocess reading ~/.claude.json.
    // NOTE: Overridden to ['project'] in canUseTool branch below to prevent pre-approved
    // permission rules from bypassing the permission bridge.
    settingSources: ['user', 'project', 'local'] as Options['settingSources'],
    ...(claudePath ? { pathToClaudeCodeExecutable: claudePath } : {}),
  }

  // Map Forge thinkingMode to SDK thinking + effort (claude-agent-sdk@0.3.196+)
  const thinkingMode = normalizeThinkingMode(opts.thinkingMode)
  const thinkingOpts = buildThinkingOptions(thinkingMode, capabilities)
  if (thinkingOpts.thinking) sdkOptions.thinking = thinkingOpts.thinking
  if (thinkingOpts.effort) sdkOptions.effort = thinkingOpts.effort

  // Agents (only if any exist)
  if (Object.keys(agents).length > 0) {
    sdkOptions.agents = agents
  }

  // MCP servers (skipped for IM queries to reduce startup time)
  if (mcpServers) {
    sdkOptions.mcpServers = mcpServers
  }

  // Permission handling
  //
  // IMPORTANT: The CLI has a hardcoded safetyCheck for .claude/ directory that
  // blocks Edit/Write even in bypassPermissions mode (confirmed SDK bug — see
  // GitHub issues #38806, #35718, #36497). The safetyCheck returns "ask" BEFORE
  // the bypassPermissions check runs, so the only way to approve .claude/ writes
  // is via canUseTool callback. We always provide one that auto-approves .claude/
  // paths, wrapping the caller's canUseTool if provided.
  const isClaudeDirWrite = (input: Record<string, unknown>): boolean => {
    const filePath = String(input.file_path || input.path || '')
    return filePath.includes('/.claude/') || filePath.includes('\\.claude\\')
  }

  if (opts.bypassPermissions) {
    sdkOptions.permissionMode = 'bypassPermissions'
    sdkOptions.allowDangerouslySkipPermissions = true
    // Keep user/local settings from overriding per-request provider env.
    sdkOptions.settingSources = ['project'] as Options['settingSources']
    // Provide canUseTool to handle .claude/ safetyCheck (which bypasses bypassPermissions).
    // Auto-approve everything — this is what bypass mode is supposed to do.
    sdkOptions.canUseTool = async (_toolName, input, options) => ({
      behavior: 'allow' as const,
      updatedInput: input,
      updatedPermissions: options.suggestions,
    })
  } else if (opts.canUseTool) {
    // Wrap the caller's canUseTool with .claude/ auto-approval.
    // .claude/ safetyCheck prompts can't be displayed in the UI (no clickable button),
    // so we auto-approve them and return SDK suggestions to unlock the session.
    const callerCanUseTool = opts.canUseTool
    sdkOptions.canUseTool = async (toolName, input, options) => {
      if (isClaudeDirWrite(input)) {
        return {
          behavior: 'allow' as const,
          updatedInput: input,
          updatedPermissions: options.suggestions,
        }
      }
      return callerCanUseTool(toolName, input, options)
    }
    // CRITICAL: Restrict settingSources to only 'project' when canUseTool is provided.
    // Without this, the SDK loads pre-approved permission rules from:
    //   - ~/.claude/settings.json ('user' source) — e.g. mcp__pencil
    //   - ~/.claude/settings.local.json ('local' source) — e.g. WebSearch, Bash(*)
    // and auto-approves those tools WITHOUT ever calling canUseTool, completely
    // bypassing Forge's permission bridge.
    // Keeping 'project' ensures <project>/.claude/CLAUDE.md and project settings load.
    // Provider auth stays per-request via sdkOptions.env.
    sdkOptions.settingSources = ['project'] as Options['settingSources']
  }

  // Abort controller
  if (opts.abortController) {
    sdkOptions.abortController = opts.abortController
  }

  // Stderr handler: log SDK subprocess errors for debugging
  sdkOptions.stderr = (data: string) => {
    const cleaned = data
      .replace(/\x1B\[[0-9;]*[a-zA-Z]/g, '')
      .replace(/\x1B\][^\x07\x1B]*(?:\x07|\x1B\\)/g, '')
      .replace(/[\x00-\x08\x0B\x0C\x0E-\x1F]/g, '')
      .trim()
    if (cleaned) {
      console.error('[forge-sdk-stderr]', cleaned.slice(0, 500))
    }
  }

  // If no attachments, use simple string prompt
  if (!opts.attachments || opts.attachments.length === 0) {
    return query({ prompt: opts.prompt, options: sdkOptions })
  }

  // Build multimodal SDKUserMessage with content blocks
  const contentBlocks = buildMultimodalContent(opts.prompt, opts.attachments)
  const userMessage: SDKUserMessage = {
    type: 'user',
    message: { role: 'user', content: contentBlocks },
    parent_tool_use_id: null,
    session_id: opts.sessionId,
  }

  // Use AsyncIterable<SDKUserMessage> prompt format
  async function* singleMessage(): AsyncIterable<SDKUserMessage> {
    yield userMessage
  }

  return query({ prompt: singleMessage(), options: sdkOptions })
}

/**
 * Debug helper: log system prompt composition to console and write the full
 * resolved system prompt to disk so operators can inspect exactly what the
 * SDK subprocess receives.
 */
function logSystemPrompt(
  workspaceId: string,
  userId: string | undefined,
  systemPrompt: string,
  resolvedSystemPrompt: string,
  opts: ForgeQueryOptions,
): void {
  if (process.env.DEBUG_SYSTEM_PROMPT !== 'true') return

  try {
    // Keep debug logs inside the project logs/ directory alongside other runtime logs.
    const projectRoot = process.cwd()
    const logsDir = path.join(projectRoot, 'logs', 'system-prompts')
    fs.mkdirSync(logsDir, { recursive: true })

    const sessionShort = opts.sessionId.slice(0, 8)
    const logPath = path.join(logsDir, `system-prompt-${sessionShort}.md`)
    const latestPath = path.join(projectRoot, 'logs', 'system-prompt-latest.md')

    const entryLines = [
      '',
      '---',
      '',
      `# System Prompt Debug Log — ${new Date().toISOString()}`,
      `- sessionId: ${opts.sessionId}`,
      `- workspaceId: ${workspaceId}`,
      `- userId: ${userId || 'none'}`,
      `- model: ${opts.model}`,
      `- useImPrompt: ${opts.useImPrompt || false}`,
      `- customSystemPrompt: ${opts.customSystemPrompt ? 'yes' : 'no'}`,
      `- isFeishuChannel: ${opts.isFeishuChannel || false}`,
      `- feishuAuth: ${opts.feishuAuth ? 'yes' : 'no'}`,
      `- base prompt length: ${systemPrompt.length}`,
      `- resolved prompt length: ${resolvedSystemPrompt.length}`,
      `- feishu guide injected: ${opts.isFeishuChannel ? 'yes' : 'no'}`,
      '',
      '---',
      '',
      resolvedSystemPrompt,
    ]

    // Append per-session history so one session produces a single log file.
    fs.appendFileSync(logPath, entryLines.join('\n'), 'utf-8')
    // Also keep a stable "latest" copy for quick inspection (overwritten each call).
    fs.writeFileSync(latestPath, entryLines.join('\n'), 'utf-8')

    console.log(
      `[createForgeQuery] systemPrompt: base=${systemPrompt.length} chars, resolved=${resolvedSystemPrompt.length} chars, ` +
      `imPrompt=${opts.useImPrompt || false}, feishu=${opts.isFeishuChannel || false}, log=${logPath}`,
    )
  } catch (err) {
    console.warn('[createForgeQuery] Failed to log system prompt:', err instanceof Error ? err.message : err)
  }
}

/**
 * Build multimodal content blocks from text prompt + file attachments.
 *
 * Images → base64 image block (Claude native vision) — the prompt already
 * includes a disk-path fallback hint so the Agent can use Read if the
 * base64 path fails (dual-path insurance).
 *
 * Non-image files → no content block injected here. The prompt already
 * contains file path references and a "Please read using your Read tool"
 * instruction, giving the Agent full autonomy to handle any format.
 */
function buildMultimodalContent(prompt: string, attachments: ForgeAttachment[]): ContentBlockParam[] {
  const fs = require('fs') as typeof import('fs')
  const blocks: ContentBlockParam[] = []

  for (const att of attachments) {
    try {
      if (att.tier === 'image') {
        // Image → base64 image content block (dual-path: prompt also has disk path)
        const data = fs.readFileSync(att.serverPath)
        const base64 = data.toString('base64')
        const mediaType = att.mimeType as 'image/jpeg' | 'image/png' | 'image/gif' | 'image/webp'
        blocks.push({
          type: 'image',
          source: { type: 'base64', media_type: mediaType, data: base64 },
        })
      }
      // Non-image ('file' tier): no content block needed.
      // The prompt already contains the file path and Read tool hint.
    } catch (err) {
      console.warn(`[SDK] Failed to read attachment ${att.name}:`, err instanceof Error ? err.message : err)
      blocks.push({ type: 'text', text: `[Failed to read file: ${att.name}]` })
    }
  }

  // Add the user's text prompt (includes file path hints for non-image files)
  if (prompt.trim()) {
    blocks.push({ type: 'text', text: prompt })
  }

  return blocks
}

// ── Thinking / Effort Mapping ───────────────────────────────────

function getModelCapabilities(modelId: string, userId?: string): ModelCapabilities {
  if (!userId) return {}
  const stored = providerModelRepo.listByUser(userId).find((m) => m.modelId === modelId)
  if (!stored) return {}
  return stored.capabilities as ModelCapabilities
}

function normalizeThinkingMode(mode?: string): 'off' | 'auto' | 'max' {
  if (mode === 'off' || mode === 'max') return mode
  return 'auto'
}

function buildThinkingOptions(
  mode: 'off' | 'auto' | 'max',
  capabilities: ModelCapabilities,
): { thinking?: ThinkingConfig; effort?: EffortLevel } {
  if (!capabilities.supportsThinking) {
    return { thinking: { type: 'disabled' } }
  }

  switch (mode) {
    case 'off':
      return { thinking: { type: 'disabled' } }
    case 'auto':
      return capabilities.supportsAdaptiveThinking
        ? { thinking: { type: 'adaptive' } }
        : { thinking: { type: 'disabled' } }
    case 'max': {
      const result: { thinking?: ThinkingConfig; effort?: EffortLevel } = {}
      if (capabilities.supportsAdaptiveThinking) {
        result.thinking = { type: 'adaptive' }
      } else if (capabilities.supportsThinking) {
        result.thinking = { type: 'enabled', budgetTokens: 32_000 }
      }
      if (capabilities.supportsEffort) {
        result.effort = 'max'
      }
      return result
    }
  }
}
