/**
 * Claude Agent SDK wrapper — v2 Long-lived Query Model
 *
 * Two modes:
 * 1. Live Conversation (main chat + admin WhatsApp):
 *    Single long-lived query() per conversation. User messages pushed into async queue.
 *    Agent stays alive, processes messages as they arrive, reports sub-agent completions.
 *
 * 2. One-shot Query (customer WhatsApp, scheduler):
 *    Classic request-response: one query() per message. Backward compat.
 */

import { query, type SDKMessage, type SDKUserMessage, type Options } from '@anthropic-ai/claude-agent-sdk';
import fs from 'fs';
import path from 'path';
import { log } from '../../shared/logger.js';
import { WORKSPACE_DIR } from '../../shared/paths.js';
import type { SavedFile } from '../file-saver.js';
import { getClaudeAccessToken } from '../../worker/claude-auth.js';
import { assembleSystemPrompt } from '../../worker/prompts/prompt-assembler.js';
import { buildAgents } from '../agents/index.js';
import { preWarm, claimWarmup, discardWarmup } from '../cli-warmup.js';

// ── Types ──────────────────────────────────────────────────────────────────

import type { RecentMessage, AgentAttachment, AgentQueryRequest, AgentQueryResult } from './types.js';
export type { RecentMessage, AgentAttachment };

// ── Async Queue ────────────────────────────────────────────────────────────

interface AsyncQueue<T> extends AsyncIterable<T> {
  push(item: T): void;
  end(): void;
}

/** Create an async queue that can be used as an AsyncIterable prompt for the SDK */
function createAsyncQueue<T>(): AsyncQueue<T> {
  const pending: T[] = [];
  let resolve: ((value: IteratorResult<T>) => void) | null = null;
  let done = false;

  return {
    push(item: T) {
      if (done) return;
      if (resolve) {
        resolve({ value: item, done: false });
        resolve = null;
      } else {
        pending.push(item);
      }
    },
    end() {
      done = true;
      if (resolve) resolve({ value: undefined as any, done: true });
    },
    [Symbol.asyncIterator]() {
      return {
        next(): Promise<IteratorResult<T>> {
          if (pending.length > 0) {
            return Promise.resolve({ value: pending.shift()!, done: false });
          }
          if (done) return Promise.resolve({ value: undefined as any, done: true });
          return new Promise((r) => { resolve = r; });
        },
      };
    },
  };
}

// ── Live Conversation Manager ──────────────────────────────────────────────

interface LiveConversation {
  id: string;
  inputQueue: AsyncQueue<SDKUserMessage>;
  abortController: AbortController;
  queryHandle: any;
  onMessage: (type: string, data: any) => void;
  /** True while the model is actively processing (between message push and result) */
  busy: boolean;
  /** Messages pushed but not yet completed (1 result per message). Used to know when
   *  the session is truly idle — i.e. no queued message — so it's safe to recycle. */
  pendingCount: number;
}

const liveConversations = new Map<string, LiveConversation>();

/** Check if a live conversation exists */
export function hasConversation(conversationId: string): boolean {
  return liveConversations.has(conversationId);
}

/** End all live conversations (e.g. after re-auth so they restart with fresh token) */
export function endAllConversations(): void {
  for (const convId of liveConversations.keys()) {
    log.info(`[conversation] Ending conversation ${convId} (auth changed)`);
    endConversation(convId);
  }
  // The pre-warmed subprocess was initialized with the old OAuth token — drop it.
  discardWarmup();
}

// ── Helpers ─────────────────────────────────────────────────────────────────

/** Read a memory file from workspace, returning '(empty)' if missing or empty */
function readMemoryFile(filename: string): string {
  try {
    const content = fs.readFileSync(path.join(WORKSPACE_DIR, filename), 'utf-8').trim();
    return content || '(empty)';
  } catch {
    return '(empty)';
  }
}

/** Read all memory + config files */
function readMemoryFiles() {
  return {
    myself: readMemoryFile('MYSELF.md'),
    myhuman: readMemoryFile('MYHUMAN.md'),
    memory: readMemoryFile('MEMORY.md'),
    pulse: readMemoryFile('PULSE.json'),
    crons: readMemoryFile('CRONS.json'),
  };
}

/** Format recent messages as conversation history text */
function formatConversationHistory(messages: RecentMessage[]): string {
  if (!messages.length) return '';
  return messages.map((m) => `${m.role}: ${m.content}`).join('\n\n');
}

/** Load MCP server config from workspace/MCP.json */
function loadMcpServers(): Record<string, any> | undefined {
  try {
    const mcpConfigPath = path.join(WORKSPACE_DIR, 'MCP.json');
    const mcpConfig = JSON.parse(fs.readFileSync(mcpConfigPath, 'utf-8'));
    if (mcpConfig && typeof mcpConfig === 'object' && !Array.isArray(mcpConfig) && Object.keys(mcpConfig).length) {
      return mcpConfig;
    } else if (Array.isArray(mcpConfig) && mcpConfig.length) {
      return Object.assign({}, ...mcpConfig);
    }
  } catch {}
  return undefined;
}

/** Build an SDKUserMessage from text + optional attachments */
function buildUserMessage(text: string, attachments?: AgentAttachment[], savedFiles?: SavedFile[]): SDKUserMessage {
  const content: any[] = [];

  if (attachments?.length) {
    for (const att of attachments) {
      if (att.type === 'image') {
        content.push({
          type: 'image',
          source: { type: 'base64', media_type: att.mediaType, data: att.data },
        });
      } else {
        content.push({
          type: 'document',
          source: { type: 'base64', media_type: att.mediaType, data: att.data },
        });
      }
    }
  }

  let promptText = text || '(attached files)';
  if (savedFiles?.length) {
    const lines = savedFiles.map((f) => `- ${f.name} -> ${f.relPath}`);
    promptText += `\n\n[Attached files saved to disk]\n${lines.join('\n')}\nYou can read or reference these files using the paths above (relative to your cwd).`;
  }

  content.push({ type: 'text', text: promptText });

  return {
    type: 'user' as const,
    message: { role: 'user' as const, content },
    parent_tool_use_id: null,
  } as SDKUserMessage;
}

// ── Live Conversation API ──────────────────────────────────────────────────

/**
 * Build the options for a live conversation's query(). Shared by
 * `startConversation` and the boot-time pre-warmer so a warmed subprocess
 * has byte-identical options.
 */
async function buildConversationOptions(
  model: string,
  oauthToken: string,
  names?: { botName: string; humanName: string },
  recentMessages?: RecentMessage[],
): Promise<Omit<Options, 'abortController' | 'stderr'>> {
  const memoryFiles = readMemoryFiles();
  const basePrompt = await assembleSystemPrompt(names?.botName, names?.humanName, 'claude');
  let systemPrompt = basePrompt;
  systemPrompt += `\n\n---\n# Your Memory Files\n\n## MYSELF.md\n${memoryFiles.myself}\n\n## MYHUMAN.md\n${memoryFiles.myhuman}\n\n## MEMORY.md\n${memoryFiles.memory}\n\n---\n# Your Config Files\n\n## PULSE.json\n${memoryFiles.pulse}\n\n## CRONS.json\n${memoryFiles.crons}`;

  try {
    const { loadConfig: loadCfg } = await import('../../shared/config.js');
    const cfg = loadCfg();
    const channels = (cfg as any).channels;
    if (channels) {
      systemPrompt += `\n\n---\n# Channel Config\n\`\`\`json\n${JSON.stringify(channels, null, 2)}\n\`\`\``;
    }
  } catch {}

  if (recentMessages?.length) {
    systemPrompt += `\n\n---\n# Recent Conversation\n${formatConversationHistory(recentMessages)}`;
  }

  const agents = buildAgents();
  const mcpServers = loadMcpServers();

  return {
    model,
    // Reasoning effort. 'high' = deep reasoning while staying more token-efficient
    // than the CLI's xhigh default on Opus 4.7/4.8 — meaningful given Anthropic's
    // tighter third-party usage limits. Supported on Opus 4.6+/Sonnet 4.6; silently
    // ignored by models without effort support.
    effort: 'high',
    cwd: WORKSPACE_DIR,
    permissionMode: 'bypassPermissions',
    allowDangerouslySkipPermissions: true,
    systemPrompt,
    mcpServers,
    agents,
    agentProgressSummaries: true,
    // Auto-compaction: the live conversation is a single long-lived query() whose
    // context grows every turn (messages + tool results + sub-agent transcripts).
    // Enable it explicitly via inline settings so it does NOT depend on filesystem
    // settings.json being present — when context fills, the SDK summarizes older
    // history and continues instead of hitting the hard context wall.
    settings: { autoCompactEnabled: true },
    env: {
      ...process.env as Record<string, string>,
      CLAUDE_CODE_OAUTH_TOKEN: oauthToken,
      CLAUDE_CODE_BUBBLEWRAP: '1',
    },
  };
}

/**
 * Pre-warm the Claude CLI subprocess for the next live conversation. Call
 * fire-and-forget at supervisor boot (and after a conversation ends) so the
 * first user message doesn't pay CLI startup latency.
 */
export async function warmUpForLiveConversation(
  model: string,
  names?: { botName: string; humanName: string },
): Promise<void> {
  if (!model) return;
  try {
    const oauthToken = await getClaudeAccessToken();
    if (!oauthToken) return;
    const options = await buildConversationOptions(model, oauthToken, names);
    await preWarm(options);
  } catch (err: any) {
    log.warn(`[conversation] Warm-up skipped: ${err?.message || err}`);
  }
}

/**
 * Start a long-lived conversation.
 * Creates a single query() with an async input queue.
 * Messages are pushed via pushMessage(). The query stays alive until endConversation().
 */
export async function startConversation(
  conversationId: string,
  model: string,
  onMessage: (type: string, data: any) => void,
  names?: { botName: string; humanName: string },
  recentMessages?: RecentMessage[],
): Promise<boolean> {
  log.info(`[conversation] ──── STARTING CONVERSATION ────`);
  log.info(`[conversation] Conv ID: ${conversationId}`);
  log.info(`[conversation] Model: ${model}`);

  // End any existing conversation with this ID
  if (liveConversations.has(conversationId)) {
    log.info(`[conversation] Ending existing conversation ${conversationId} before starting new one`);
    endConversation(conversationId);
  }

  const oauthToken = await getClaudeAccessToken();
  if (!oauthToken) {
    log.warn('[conversation] No OAuth token — cannot start');
    onMessage('bot:error', { conversationId, error: 'Claude OAuth token not found. Please authenticate via the dashboard.' });
    return false;
  }

  const baseOptions = await buildConversationOptions(model, oauthToken, names, recentMessages);
  const systemPromptLen = typeof baseOptions.systemPrompt === 'string' ? baseOptions.systemPrompt.length : 0;
  log.info(`[conversation] Loaded ${Object.keys(baseOptions.agents || {}).length} sub-agent(s): ${Object.keys(baseOptions.agents || {}).join(', ')}`);
  if (baseOptions.mcpServers) {
    log.info(`[conversation] MCP servers: ${Object.keys(baseOptions.mcpServers).join(', ')}`);
  }

  // Try to claim a pre-warmed subprocess — its abortController is the one
  // baked into the warm query and must be reused for end/abort to reach it.
  const claimed = claimWarmup(baseOptions);
  const abortController = claimed?.abortController ?? new AbortController();

  // Create the async input queue
  const inputQueue = createAsyncQueue<SDKUserMessage>();

  // Store the conversation
  const conv: LiveConversation = {
    id: conversationId,
    inputQueue,
    abortController,
    queryHandle: null,
    onMessage,
    busy: false,
    pendingCount: 0,
  };
  liveConversations.set(conversationId, conv);

  log.info(`[conversation] System prompt: ${systemPromptLen} chars`);
  log.info(`[conversation] Starting long-lived query... (${claimed ? 'warm' : 'cold'})`);

  // Run the for-await loop in the background (fire and forget)
  (async () => {
    let fullText = '';
    const usedTools = new Set<string>();
    let stderrBuf = '';

    try {
      const claudeQuery = claimed
        ? claimed.warmQuery.query(inputQueue)
        : query({
            prompt: inputQueue,
            options: {
              ...baseOptions,
              abortController,
              stderr: (chunk: string) => { stderrBuf += chunk; },
            },
          });

      conv.queryHandle = claudeQuery;
      log.info(`[conversation] ──── QUERY LOOP STARTED ────`);

      for await (const msg of claudeQuery) {
        if (abortController.signal.aborted) {
          log.info(`[conversation] Query aborted — exiting loop`);
          break;
        }

        switch (msg.type) {
          case 'assistant': {
            const assistantMsg = msg.message;
            if (!assistantMsg?.content) break;

            for (const block of assistantMsg.content) {
              if (block.type === 'text' && block.text) {
                if (fullText && !fullText.endsWith('\n')) {
                  fullText += '\n\n';
                  onMessage('bot:token', { conversationId, token: '\n\n' });
                }
                fullText += block.text;
                onMessage('bot:token', { conversationId, token: block.text });
              } else if (block.type === 'tool_use') {
                usedTools.add(block.name);
                onMessage('bot:tool', { conversationId, name: block.name, input: block.input });
              }
            }
            break;
          }

          case 'result': {
            // Agent finished processing the current message
            log.info(`[conversation] ──── TURN COMPLETE ────`);
            log.info(`[conversation] Response length: ${fullText.length} chars`);
            log.info(`[conversation] Tools used this turn: ${Array.from(usedTools).join(', ') || 'none'}`);

            if (fullText) {
              onMessage('bot:response', { conversationId, content: fullText });
              fullText = '';
            } else if (msg.subtype?.startsWith('error')) {
              const errorText = (msg as any).errors?.join('; ') || 'Agent turn failed';
              log.warn(`[conversation] Turn error: ${errorText}`);
              onMessage('bot:error', { conversationId, error: errorText });
            }

            // Signal turn complete — backend restart + UI update
            const FILE_TOOLS = ['Write', 'Edit', 'MultiEdit', 'NotebookEdit'];
            const usedFileTools = FILE_TOOLS.some((t) => usedTools.has(t));

            // Context-size signal for the orchestrator's proactive session recycling.
            // Prefer modelUsage (carries the per-model contextWindow); fall back to raw usage.
            let contextTokens = 0;
            let contextWindow = 0;
            const modelUsage = (msg as any).modelUsage as Record<string, any> | undefined;
            if (modelUsage) {
              for (const mu of Object.values(modelUsage)) {
                const used = (mu?.inputTokens || 0) + (mu?.cacheReadInputTokens || 0) + (mu?.cacheCreationInputTokens || 0);
                if (used > contextTokens) { contextTokens = used; contextWindow = mu?.contextWindow || 0; }
              }
            }
            if (!contextTokens) {
              const u = (msg as any).usage || {};
              contextTokens = (u.input_tokens || 0) + (u.cache_read_input_tokens || 0) + (u.cache_creation_input_tokens || 0);
            }
            // One result per pushed message → decrement; idle when nothing else is queued.
            conv.pendingCount = Math.max(0, (conv.pendingCount || 0) - 1);
            const idle = conv.pendingCount === 0;
            onMessage('bot:turn-complete', { conversationId, usedFileTools, contextTokens, contextWindow, idle });

            // Reset per-turn state
            usedTools.clear();
            conv.busy = false;
            log.info(`[conversation] Agent idle — waiting for next message`);
            break;
          }

          case 'tool_progress':
            onMessage('bot:tool', {
              conversationId,
              name: (msg as any).tool_name || 'working',
              status: 'running',
            });
            break;

          // ── Background sub-agent events ──
          case 'system': {
            const sysMsg = msg as any;
            if (sysMsg.subtype === 'task_started') {
              log.info(`[conversation] ──── SUB-AGENT STARTED ────`);
              log.info(`[conversation] Task ID: ${sysMsg.task_id}`);
              log.info(`[conversation] Description: ${sysMsg.description}`);
              onMessage('bot:task-created', {
                conversationId,
                taskId: sysMsg.task_id,
                description: sysMsg.description,
                type: sysMsg.task_type,
              });
            } else if (sysMsg.subtype === 'task_progress') {
              const summary = sysMsg.summary || sysMsg.last_tool_name || 'working';
              log.info(`[conversation] Sub-agent ${sysMsg.task_id} | ${summary} | Tools: ${sysMsg.usage?.tool_uses || 0} | ${Math.round((sysMsg.usage?.duration_ms || 0) / 1000)}s`);
              onMessage('bot:task-progress', {
                conversationId,
                taskId: sysMsg.task_id,
                summary,
                lastTool: sysMsg.last_tool_name,
                usage: sysMsg.usage,
              });
            } else if (sysMsg.subtype === 'task_notification') {
              log.info(`[conversation] ──── SUB-AGENT ${sysMsg.status?.toUpperCase()} ────`);
              log.info(`[conversation] Task ID: ${sysMsg.task_id}`);
              log.info(`[conversation] Status: ${sysMsg.status}`);
              log.info(`[conversation] Summary: ${sysMsg.summary?.slice(0, 200)}`);
              log.info(`[conversation] Tokens: ${sysMsg.usage?.total_tokens || 0} | Tools: ${sysMsg.usage?.tool_uses || 0} | Duration: ${Math.round((sysMsg.usage?.duration_ms || 0) / 1000)}s`);
              onMessage('bot:task-done', {
                conversationId,
                taskId: sysMsg.task_id,
                status: sysMsg.status,
                summary: sysMsg.summary,
                usage: sysMsg.usage,
              });
              // Don't emit bot:turn-complete here. Sub-agent completion is a progress
              // signal, not a turn boundary — the parent agent will continue, and its
              // real `result` event (above) is the only true turn end. The parent's
              // `usedTools` Set already captures sub-agent tool_use blocks, so file
              // edits made inside a sub-agent are still reflected in usedFileTools.
            }
            break;
          }
        }
      }

      log.info(`[conversation] ──── QUERY LOOP ENDED ────`);

      // Send any remaining text
      if (fullText && !abortController.signal.aborted) {
        onMessage('bot:response', { conversationId, content: fullText });
      }
    } catch (err: any) {
      if (!abortController.signal.aborted) {
        const detail = stderrBuf.trim();
        const errMsg = detail ? `${err.message}\n\nCLI stderr:\n${detail}` : err.message;
        log.warn(`[conversation] Query error: ${errMsg}`);
        onMessage('bot:error', { conversationId, error: errMsg });
      }
    } finally {
      log.info(`[conversation] Cleaning up conversation ${conversationId}`);
      liveConversations.delete(conversationId);
      onMessage('bot:conversation-ended', { conversationId });
      // Pre-warm a fresh subprocess for the next live conversation (fire-and-forget).
      warmUpForLiveConversation(model, names);
    }
  })();

  return true;
}

/**
 * Push a user message into an existing live conversation.
 * The agent will process it as part of the ongoing conversation.
 */
export function pushMessage(
  conversationId: string,
  content: string,
  attachments?: AgentAttachment[],
  savedFiles?: SavedFile[],
): boolean {
  const conv = liveConversations.get(conversationId);
  if (!conv) {
    log.warn(`[conversation] pushMessage — no live conversation ${conversationId}`);
    return false;
  }

  log.info(`[conversation] ──── PUSH MESSAGE ────`);
  log.info(`[conversation] Conv: ${conversationId}`);
  log.info(`[conversation] Content: "${content.slice(0, 100)}..."`);
  log.info(`[conversation] Attachments: ${attachments?.length || 0}`);
  log.info(`[conversation] Agent busy: ${conv.busy}`);

  const userMessage = buildUserMessage(content, attachments, savedFiles);
  conv.busy = true;
  conv.pendingCount = (conv.pendingCount || 0) + 1;
  conv.inputQueue.push(userMessage);

  // Emit typing indicator
  conv.onMessage('bot:typing', { conversationId });

  return true;
}

/** End a live conversation */
export function endConversation(conversationId: string): void {
  const conv = liveConversations.get(conversationId);
  if (!conv) return;

  log.info(`[conversation] ──── ENDING CONVERSATION ────`);
  log.info(`[conversation] Conv: ${conversationId}`);

  conv.inputQueue.end();
  conv.abortController.abort();
  liveConversations.delete(conversationId);
}

/** Check if the agent is currently busy processing a message */
export function isConversationBusy(conversationId: string): boolean {
  return liveConversations.get(conversationId)?.busy || false;
}

/** True if ANY live conversation in this harness is mid-turn. Used by the supervisor to defer
 *  backend restarts during channel/Alexa turns (which don't set the dashboard's agentQueryActive). */
export function anyConversationBusy(): boolean {
  for (const c of liveConversations.values()) if (c.busy) return true;
  return false;
}

/** True while any one-shot startBlobyAgentQuery (pulse/cron, customer WhatsApp) is in flight.
 *  These register only in activeQueries (cleared in a finally), not liveConversations, so
 *  anyConversationBusy() can't see them. */
export function anyOneShotActive(): boolean {
  return activeQueries.size > 0;
}

/** Stop a specific background sub-agent task */
export async function stopSubAgentTask(conversationId: string, taskId: string): Promise<void> {
  const conv = liveConversations.get(conversationId);
  if (conv?.queryHandle?.stopTask) {
    log.info(`[conversation] Stopping sub-agent task: ${taskId}`);
    await conv.queryHandle.stopTask(taskId);
  } else {
    log.warn(`[conversation] Cannot stop task ${taskId} — no live conversation ${conversationId}`);
  }
}

// ── One-shot Query API (backward compat) ────────────────────────────────────
// Used by: customer WhatsApp (handleCustomerMessage), scheduler (triggerAgent)

interface ActiveQuery {
  abortController: AbortController;
}

const activeQueries = new Map<string, ActiveQuery>();

/**
 * Run a one-shot Agent SDK query (classic request-response).
 * Used for customer-facing messages and scheduler triggers.
 */
export async function startBlobyAgentQuery(
  conversationId: string,
  prompt: string,
  model: string,
  onMessage: (type: string, data: any) => void,
  attachments?: AgentAttachment[],
  savedFiles?: SavedFile[],
  names?: { botName: string; humanName: string },
  recentMessages?: RecentMessage[],
  supportPrompt?: string,
  maxTurns?: number,
): Promise<void> {
  const oauthToken = await getClaudeAccessToken();
  if (!oauthToken) {
    onMessage('bot:error', { conversationId, error: 'Claude OAuth token not found. Please authenticate via the dashboard.' });
    return;
  }

  const abortController = new AbortController();
  const memoryFiles = readMemoryFiles();

  let enrichedPrompt: string;
  if (supportPrompt) {
    enrichedPrompt = supportPrompt;
  } else {
    const basePrompt = await assembleSystemPrompt(names?.botName, names?.humanName, 'claude');
    enrichedPrompt = basePrompt;
    enrichedPrompt += `\n\n---\n# Your Memory Files\n\n## MYSELF.md\n${memoryFiles.myself}\n\n## MYHUMAN.md\n${memoryFiles.myhuman}\n\n## MEMORY.md\n${memoryFiles.memory}\n\n---\n# Your Config Files\n\n## PULSE.json\n${memoryFiles.pulse}\n\n## CRONS.json\n${memoryFiles.crons}`;

    try {
      const { loadConfig: loadCfg } = await import('../../shared/config.js');
      const cfg = loadCfg();
      const channels = (cfg as any).channels;
      if (channels) {
        enrichedPrompt += `\n\n---\n# Channel Config\n\`\`\`json\n${JSON.stringify(channels, null, 2)}\n\`\`\``;
      }
    } catch {}
  }

  if (recentMessages?.length) {
    enrichedPrompt += `\n\n---\n# Recent Conversation\n${formatConversationHistory(recentMessages)}`;
  }

  activeQueries.set(conversationId, { abortController });

  // Hard watchdog: a hung CLI subprocess (network stall, stuck MCP) would otherwise leave the
  // `for await` loop pending forever — the finally never runs, bot:done never fires, and the
  // caller's per-conversation slot (WhatsApp activeAgents / scheduler) is pinned for good. Abort
  // after 5 min so the finally always emits bot:done. Cleared on normal completion.
  const watchdog = setTimeout(() => {
    log.warn(`[bloby-agent] One-shot query timed out (5m) — aborting conv=${conversationId}`);
    abortController.abort();
  }, 300_000);

  let fullText = '';
  const usedTools = new Set<string>();
  let stderrBuf = '';

  let plainPrompt = prompt;
  if (savedFiles?.length && !attachments?.length) {
    const lines = savedFiles.map((f) => `- ${f.name} -> ${f.relPath}`);
    plainPrompt += `\n\n[Attached files saved to disk]\n${lines.join('\n')}\nYou can read or reference these files using the paths above (relative to your cwd).`;
  }

  const sdkPrompt: string | AsyncIterable<SDKUserMessage> =
    attachments?.length ? (async function* () {
      yield buildUserMessage(prompt, attachments, savedFiles);
    })() : plainPrompt;

  try {
    const mcpServers = loadMcpServers();
    const effectiveMaxTurns = maxTurns ?? 50;

    log.info(`[bloby-agent] One-shot query: conv=${conversationId}, maxTurns=${effectiveMaxTurns}`);

    const claudeQuery = query({
      prompt: sdkPrompt,
      options: {
        model,
        effort: 'high', // see buildConversationOptions — token-efficient deep reasoning
        cwd: WORKSPACE_DIR,
        permissionMode: 'bypassPermissions',
        allowDangerouslySkipPermissions: true,
        maxTurns: effectiveMaxTurns,
        abortController,
        systemPrompt: enrichedPrompt,
        mcpServers,
        stderr: (chunk: string) => { stderrBuf += chunk; },
        env: {
          ...process.env as Record<string, string>,
          CLAUDE_CODE_OAUTH_TOKEN: oauthToken,
          CLAUDE_CODE_BUBBLEWRAP: '1',
        },
      },
    });

    onMessage('bot:typing', { conversationId });

    for await (const msg of claudeQuery) {
      if (abortController.signal.aborted) break;

      switch (msg.type) {
        case 'assistant': {
          const assistantMsg = msg.message;
          if (!assistantMsg?.content) break;
          for (const block of assistantMsg.content) {
            if (block.type === 'text' && block.text) {
              if (fullText && !fullText.endsWith('\n')) {
                fullText += '\n\n';
                onMessage('bot:token', { conversationId, token: '\n\n' });
              }
              fullText += block.text;
              onMessage('bot:token', { conversationId, token: block.text });
            } else if (block.type === 'tool_use') {
              usedTools.add(block.name);
              onMessage('bot:tool', { conversationId, name: block.name, input: block.input });
            }
          }
          break;
        }
        case 'result': {
          if (fullText) {
            onMessage('bot:response', { conversationId, content: fullText });
            fullText = '';
          } else if (msg.subtype?.startsWith('error')) {
            onMessage('bot:error', { conversationId, error: (msg as any).errors?.join('; ') || 'Agent query failed' });
          }
          break;
        }
        case 'tool_progress':
          onMessage('bot:tool', { conversationId, name: (msg as any).tool_name || 'working', status: 'running' });
          break;
      }
    }

    if (fullText && !abortController.signal.aborted) {
      onMessage('bot:response', { conversationId, content: fullText });
    }
  } catch (err: any) {
    if (!abortController.signal.aborted) {
      const detail = stderrBuf.trim();
      const errMsg = detail ? `${err.message}\n\nCLI stderr:\n${detail}` : err.message;
      log.warn(`Bloby agent error (${conversationId}): ${errMsg}`);
      onMessage('bot:error', { conversationId, error: errMsg });
    }
  } finally {
    clearTimeout(watchdog);
    activeQueries.delete(conversationId);
    const FILE_TOOLS = ['Write', 'Edit', 'MultiEdit', 'NotebookEdit'];
    const usedFileTools = FILE_TOOLS.some((t) => usedTools.has(t));
    onMessage('bot:done', { conversationId, usedFileTools });
  }
}

/** Stop a one-shot query */
export function stopBlobyAgentQuery(conversationId: string): void {
  const q = activeQueries.get(conversationId);
  if (q) {
    q.abortController.abort();
    activeQueries.delete(conversationId);
  }
}

// ── Workspace agent endpoint (POST /api/agent/query) ──────────────────────

export async function runAgentQuery(req: AgentQueryRequest): Promise<AgentQueryResult> {
  const oauthToken = await getClaudeAccessToken();
  if (!oauthToken) {
    return { ok: false, error: 'Claude OAuth token not found. Please authenticate via the dashboard.' };
  }

  const maxTurns = Math.min(Math.max(req.maxTurns || 25, 1), 50);
  const timeout = Math.min(Math.max(req.timeout || 120_000, 5_000), 300_000);

  // Empty/missing systemPrompt → fall back to Claude's built-in `claude_code`
  // preset (its native coding-agent prompt + tools).
  const systemPrompt: string | { type: 'preset'; preset: 'claude_code' } =
    req.systemPrompt ? req.systemPrompt : { type: 'preset', preset: 'claude_code' };

  const abortController = new AbortController();
  const timeoutHandle = setTimeout(() => abortController.abort(), timeout);

  let fullText = '';
  const usedTools = new Set<string>();
  let sessionId: string | undefined;
  let stderrBuf = '';

  try {
    log.info(`[claude/agent-api] Query: msg="${req.message.slice(0, 80)}..." maxTurns=${maxTurns} timeout=${timeout}ms resume=${req.sessionId || 'none'}`);

    const claudeQuery = query({
      prompt: req.message,
      options: {
        cwd: WORKSPACE_DIR,
        effort: 'high', // see buildConversationOptions — token-efficient deep reasoning
        permissionMode: 'bypassPermissions',
        allowDangerouslySkipPermissions: true,
        maxTurns,
        abortController,
        systemPrompt: systemPrompt as any,
        ...(req.sessionId ? { resume: req.sessionId } : {}),
        stderr: (chunk: string) => { stderrBuf += chunk; },
        env: {
          ...process.env as Record<string, string>,
          CLAUDE_CODE_OAUTH_TOKEN: oauthToken,
          CLAUDE_CODE_BUBBLEWRAP: '1',
        },
      },
    });

    for await (const msg of claudeQuery) {
      if (abortController.signal.aborted) break;

      switch (msg.type) {
        case 'assistant': {
          const assistantMsg = (msg as any).message;
          if (!assistantMsg?.content) break;
          for (const block of assistantMsg.content) {
            if (block.type === 'text' && block.text) {
              if (fullText && !fullText.endsWith('\n')) fullText += '\n\n';
              fullText += block.text;
            } else if (block.type === 'tool_use') {
              usedTools.add(block.name);
            }
          }
          break;
        }
        case 'result': {
          sessionId = (msg as any).session_id;
          if (!fullText && (msg as any).subtype?.startsWith('error')) {
            return {
              ok: false,
              error: (msg as any).errors?.join('; ') || 'Agent query failed',
              sessionId,
              toolsUsed: Array.from(usedTools),
            };
          }
          break;
        }
      }
    }

    const usedFileTools = ['Write', 'Edit', 'MultiEdit', 'NotebookEdit'].some((t) => usedTools.has(t));
    log.info(`[claude/agent-api] Done: ${fullText.length} chars, tools=[${Array.from(usedTools).join(',')}], session=${sessionId || 'unknown'}`);
    return { ok: true, response: fullText, sessionId, toolsUsed: Array.from(usedTools), usedFileTools };
  } catch (err: any) {
    if (abortController.signal.aborted) return { ok: false, error: 'Query timed out.', sessionId };
    const detail = stderrBuf.trim();
    const errMsg = detail ? `${err.message}\n\n${detail}` : err.message;
    log.warn(`[claude/agent-api] Error: ${errMsg}`);
    return { ok: false, error: errMsg, sessionId };
  } finally {
    clearTimeout(timeoutHandle);
  }
}
