import type { Agent, Session, ToolCall } from '../../types/api.js';
import * as modelContext from '../model-context.js';
import { PathScopeError } from '../path_guard.js';
import { TOOLS_BY_NAME, type ToolExecCtx } from "../tools.js";
import { matchToolGlob } from '../agents.js';
import { maybeFlagForCompaction } from './payload.js';
import { insertParts, partsFromAssistantMessage, partsFromToolMessage } from './parts.js';
import { getServerPermission } from '../mcp-client.js';
// v1.13.16: richer unknown-tool error so the model can self-correct when it
// drifts to a Claude Code tool name (e.g. read_file → suggest view_file).
// Applies to all unknown tool names, not just <invoke>-derived ones — at the
// dispatch layer we no longer know which format produced the call, and the
// extra signal is harmless for Qwen-derived calls.
import { formatUnknownToolError } from './tool-suggestions.js';
// v1.13.17-cross-repo-reads: pre-prompt validation for request_read_access.
// Resolves the grant root before pausing the loop so the user is never
// prompted about paths we couldn't grant anyway (e.g. /etc/passwd).
import { resolveGrantRoot } from '../grant_resolver.js';
import { stripToolMarkup } from './tool-call-parser.js';
import { repairToolInput } from './tool-input-repair.js';
import { diffFromToolArgs, isWriteTool } from './compute-diff.js';
import type { FailureKind } from './mistake-tracker.js';
import { insertToolTrace, updateToolTrace } from '../tool-traces.js';
import type {
  InferenceContext,
  StreamResult,
  TurnArgs,
} from './types.js';
// v1.13.13: synthesis pipeline — replaces the immediate recursive turn when
// any of this batch's tool calls is in SYNTHESIS_TOOLS. Falls through to
// recursion on synthesis failure (timeout / model error). See module header
// in synthesisPipeline.ts for the auto-fetch + token-budget rules.
import { SYNTHESIS_TOOLS, runSynthesisPass } from '../synthesisPipeline.js';

async function executeToolCall(
  projectRoot: string,
  toolCall: ToolCall,
  extraRoots: readonly string[],
  toolCtx?: ToolExecCtx,
  hooks?: import('../hooks.js').HookRunner,
  sessionId?: string,
): Promise<{ output: unknown; truncated: boolean; error?: string; outcome: FailureKind | 'success' }> {
  // v#12 MistakeTracker: every return path carries an `outcome` so the turn
  // loop can detect a run of heterogeneous failures. The failure taxonomy
  // mirrors mistake-tracker.ts:FailureKind. Does NOT alter the existing
  // output/truncated/error shape — outcome is purely additive.
  const tool = TOOLS_BY_NAME[toolCall.name];
  if (!tool) {
    return {
      output: null,
      truncated: false,
      error: formatUnknownToolError(toolCall.name, Object.keys(TOOLS_BY_NAME)),
      outcome: 'tool_not_found',
    };
  }
  // MCP permission gate — block deny/ask before any Zod parsing or execution
  const mcpPerm = getServerPermission(toolCall.name);
  if (mcpPerm === 'deny') {
    return { output: null, truncated: false, error: `blocked: MCP server denied tool '${toolCall.name}'`, outcome: 'permission_denied' };
  }
  if (mcpPerm === 'ask') {
    return { output: null, truncated: false, error: `requires approval: tool '${toolCall.name}' needs user approval`, outcome: 'permission_denied' };
  }
  // vWhale: schema-based tool input repair. If the Zod parse fails, attempt
  // heuristic repairs (type coercion, markdown-link unwrapping, array wrapping)
  // and retry. Logs repairs for debugging.
  let args = toolCall.args;
  let parsed = tool.inputSchema.safeParse(args);
  if (!parsed.success) {
    const schema = tool.jsonSchema?.function?.parameters;
    if (schema) {
      const { repaired: repairedArgs, repairs } = repairToolInput(
        schema as Record<string, unknown>,
        args as Record<string, unknown>,
      );
      if (repairs.length > 0) {
        const retry = tool.inputSchema.safeParse(repairedArgs);
        if (retry.success) {
          args = repairedArgs;
          parsed = retry;
        }
      }
    }
  }
  // vWhale: PreToolUse hook — can block execution.
  if (hooks && sessionId) {
    const hookResult = await hooks.run('PreToolUse', {
      event: 'PreToolUse',
      session_id: sessionId,
      tool_name: toolCall.name,
      tool_args: args as Record<string, unknown>,
    });
    if (hookResult.decision === 'block') {
      return {
        output: null,
        truncated: false,
        error: `blocked by hook: ${hookResult.reason ?? 'PreToolUse denied'}`,
        outcome: 'permission_denied',
      };
    }
    // Apply updated_input if the hook rewrote the args
    if (hookResult.updated_input && typeof hookResult.updated_input === 'object') {
      const reParsed = tool.inputSchema.safeParse(hookResult.updated_input);
      if (reParsed.success) {
        args = hookResult.updated_input as Record<string, unknown>;
        parsed = reParsed;
      }
    }
  }

  if (!parsed.success) {
    // v1.12 Track B.2: enrich the zod-reject path so the model sees a
    // one-line, tool-named hint ("tool 'search_symbols' rejected — query:
    // Required") instead of a JSON blob of flatten output. Higher recovery
    // rate on the next turn; doom-loop guard still bounds infinite retries.
    // The cast is because tool.inputSchema is ZodType<unknown>, so zod can't
    // statically narrow flatten()'s fieldErrors key set — but the runtime
    // shape is the standard { formErrors: string[]; fieldErrors: Record<...> }.
    const flatten = parsed.error.flatten() as {
      formErrors: string[];
      fieldErrors: Record<string, string[] | undefined>;
    };
    const fieldErrors = Object.entries(flatten.fieldErrors)
      .map(([field, errs]) => `${field}: ${errs?.[0] ?? 'invalid'}`)
      .join('; ');
    const formError = flatten.formErrors[0];
    const hint = fieldErrors || formError || 'unknown validation error';
    return {
      output: null,
      truncated: false,
      error: `tool '${toolCall.name}' rejected — ${hint}`,
      outcome: 'zod_reject',
    };
  }
  try {
    const output = await tool.execute(parsed.data, projectRoot, extraRoots, toolCtx);
    const truncated =
      typeof output === 'object' && output !== null && 'truncated' in output
        ? Boolean((output as { truncated: unknown }).truncated)
        : false;
    return { output, truncated, outcome: 'success' };
  } catch (err) {
    if (err instanceof PathScopeError) {
      return { output: null, truncated: false, error: err.message, outcome: 'permission_denied' };
    }
    return {
      output: null,
      truncated: false,
      error: err instanceof Error ? err.message : String(err),
      outcome: 'exec_error',
    };
  }
}

// v1.14.0: return struct from executeToolPhase so the caller (the outer
// while loop in turn.ts) can decide whether to continue, break, or handle
// synthesis. Replaces the recursive call into runAssistantTurn.
export interface ToolPhaseResult {
  action: 'continue' | 'paused' | 'synthesis_done';
  toolCallCount: number;
  toolCalls: ToolCall[];
  nextAssistantId: string | null;
  // v#12 MistakeTracker: one outcome per executed tool call, in no particular
  // order (filled inside the Promise.all callbacks). The turn loop folds these
  // into TurnArgs.mistakeTracker via recordStep. Pause/auto-grant control-flow
  // tools record 'success' (they aren't model mistakes); the genuine error
  // paths record their FailureKind.
  outcomes: (FailureKind | 'success')[];
}

export async function executeToolPhase(
  ctx: InferenceContext,
  args: TurnArgs,
  result: StreamResult,
  startedAt: string | null,
  session: Session,
  projectRoot: string,
  agent?: Agent | null,
  turnNumber?: number,
): Promise<ToolPhaseResult> {
  const { sessionId, chatId, assistantMessageId } = args;
  const content = stripToolMarkup(result.content, { final: true });
  const { toolCalls, promptTokens, completionTokens } = result;

  // v1.11.3: ctx_max comes from llama-swap /upstream/<model>/props, not the
  // streaming completion (which doesn't emit n_ctx). getModelContext caches
  // the positive lookup for the process lifetime, so this is a single Map
  // hit after the first invocation per model.
  const mctx = await modelContext.getModelContext(session.model);
  const nCtx = mctx?.n_ctx ?? null;

  const [updated] = await ctx.sql<
    { tokens_used: number | null; ctx_used: number | null; ctx_max: number | null; finished_at: string | null }[]
  >`
    UPDATE messages
    SET content = ${content},
        status = 'complete',
        tokens_used = ${completionTokens},
        ctx_used = ${promptTokens},
        ctx_max = ${nCtx},
        finished_at = clock_timestamp()
    WHERE id = ${assistantMessageId}
    RETURNING tokens_used, ctx_used, ctx_max, finished_at
  `;
  // v1.13.20: message_parts is the sole source of truth for tool_calls.
  // Legacy messages.tool_calls column was dropped; reads route through the
  // messages_with_parts view.
  // v1.13.1-C: include result.reasoning so models with separate reasoning
  // channels (qwen3.6) get a kind='reasoning' part at sequence 0.
  await insertParts(
    ctx.sql,
    partsFromAssistantMessage({
      content,
      tool_calls: toolCalls,
      reasoning: result.reasoning,
      reasoningBlocks: result.reasoningBlocks,
    }).map((p) => ({
      ...p,
      message_id: assistantMessageId,
    })),
  );
  // v1.11: flag for compaction if this turn pushed us over the usable budget.
  // We never compact mid-loop (the recursive runAssistantTurn keeps tools
  // flowing); the flag fires on the NEXT turn's pre-fetch hook above.
  await maybeFlagForCompaction(ctx, chatId, updated);
  const [toolSessRow] = await ctx.sql<{ project_id: string; name: string; updated_at: string }[]>`
    UPDATE sessions SET updated_at = clock_timestamp()
    WHERE id = ${sessionId}
    RETURNING project_id, name, updated_at
  `;
  ctx.publishUser({ type: 'session_updated', session_id: sessionId, project_id: toolSessRow!.project_id, name: toolSessRow!.name, updated_at: toolSessRow!.updated_at });
  for (const tc of toolCalls) {
    ctx.publish(sessionId, {
      type: 'tool_call',
      message_id: assistantMessageId,
      chat_id: chatId,
      tool_call: tc,
    });
  }
  ctx.publish(sessionId, {
    type: 'message_complete',
    message_id: assistantMessageId,
    chat_id: chatId,
    tokens_used: updated?.tokens_used ?? null,
    ctx_used: updated?.ctx_used ?? null,
    ctx_max: updated?.ctx_max ?? null,
    cache_tokens: result.cacheReadTokens ?? null,
    reasoning_tokens: result.reasoningTokens ?? null,
    started_at: startedAt,
    finished_at: updated?.finished_at ?? null,
    model: session.model,
  });

  // Batch 9.7: ask_user_input pauses the loop. The tool row is still inserted
  // (the answer endpoint needs a target row to UPDATE), but tool_results is
  // pre-stamped with output=null as a "pending" sentinel and no tool_result
  // frame goes out — the card renders from the tool_call frame alone. Mixed
  // batches still execute the other tools normally.
  ctx.publishUser({ type: 'chat_status', chat_id: chatId, status: 'tool_running', at: new Date().toISOString() });
  let pausingForUserInput = false;
  // v1.13.13: capture synth-tool result text so the synthesis pipeline below
  // doesn't have to re-fetch from DB. Array (not single) because a batch
  // could theoretically include multiple synthesis tools — we take the first
  // for the synthesis input. Race-free under Promise.all because each
  // callback pushes its own captured value.
  const synthEntries: Array<{ tc: ToolCall; output: unknown; error?: string }> = [];
  // v#12 MistakeTracker: collect each tool's outcome. Concurrent pushes under
  // Promise.all are safe (each callback appends its own value; order is not
  // significant to recordStep which folds them sequentially).
  const outcomes: (FailureKind | 'success')[] = [];
  await Promise.all(
    toolCalls.map(async (tc) => {
      const [toolRow] = await ctx.sql<{ id: string }[]>`
        INSERT INTO messages (session_id, chat_id, role, content, status, created_at)
        VALUES (${sessionId}, ${chatId}, 'tool', '', 'complete', clock_timestamp())
        RETURNING id
      `;
      const toolMessageId = toolRow!.id;
      if (tc.name === 'ask_user_input') {
        pausingForUserInput = true;
        outcomes.push('success');
        const sentinel = { tool_call_id: tc.id, output: null, truncated: false };
        // v1.13.20: parts-only. The answer-endpoint UPDATE later
        // (messages.ts) will delete and re-insert this part when the user
        // submits their answer.
        await insertParts(
          ctx.sql,
          partsFromToolMessage({ tool_results: sentinel }).map((p) => ({
            ...p,
            message_id: toolMessageId,
          })),
        );
        return;
      }
      // v1.13.17-cross-repo-reads: request_read_access pauses identically to
      // ask_user_input EXCEPT for an up-front validation pass — if the path
      // can't be granted under the whitelist / repo-shape rules, surface an
      // immediate denial without prompting the user. Per design D1, we never
      // ask the user about /etc/passwd or paths outside PROJECT_ROOT_WHITELIST.
      if (tc.name === 'request_read_access') {
        const tcArgs = tc.args as { path?: unknown; reason?: unknown };
        const requested =
          typeof tcArgs.path === 'string' ? tcArgs.path : '';
        const resolution = await resolveGrantRoot(
          ctx.sql,
          requested,
          projectRoot,
          ctx.config.PROJECT_ROOT_WHITELIST,
        );
        if (!resolution.ok) {
          // Auto-deny without pausing. The model sees the reason on its
          // next turn and decides what to do. Counts as a permission_denied
          // failure for the mistake tracker (the model asked for a path it
          // can't have — a recoverable mistake it should learn from).
          outcomes.push('permission_denied');
          const stored = {
            tool_call_id: tc.id,
            output: `denied: ${resolution.reason}`,
            truncated: false,
          };
          // v1.13.20: parts-only write.
          await insertParts(
            ctx.sql,
            partsFromToolMessage({ tool_results: stored }).map((p) => ({
              ...p,
              message_id: toolMessageId,
            })),
          );
          ctx.publish(sessionId, {
            type: 'tool_result',
            tool_message_id: toolMessageId,
            chat_id: chatId,
            tool_call_id: tc.id,
            output: stored.output,
            truncated: false,
          });
          return;
        }
        // Path is plausibly grantable — install the pending sentinel and
        // pause. The grant endpoint re-derives the root at decision time
        // (state may have changed in the meantime) so we don't stash it here.
        pausingForUserInput = true;
        outcomes.push('success');
        const sentinel = { tool_call_id: tc.id, output: null, truncated: false };
        // v1.13.20: parts-only write.
        await insertParts(
          ctx.sql,
          partsFromToolMessage({ tool_results: sentinel }).map((p) => ({
            ...p,
            message_id: toolMessageId,
          })),
        );
        return;
      }
      if (agent && !matchToolGlob(tc.name, agent.tools)) {
        // Agent-scope denial — the model called a tool outside its whitelist.
        // permission_denied for the mistake tracker (the model should pick a
        // tool it's actually allowed to use).
        outcomes.push('permission_denied');
        const stored = {
          tool_call_id: tc.id,
          output: null,
          truncated: false,
          error: `tool '${tc.name}' is not allowed for agent '${agent.name}'`,
        };
        await insertParts(
          ctx.sql,
          partsFromToolMessage({ tool_results: stored }).map((p) => ({
            ...p,
            message_id: toolMessageId,
          })),
        );
        ctx.publish(sessionId, {
          type: 'tool_result',
          tool_message_id: toolMessageId,
          chat_id: chatId,
          tool_call_id: tc.id,
          output: stored.output,
          truncated: false,
          error: stored.error,
        });
        return;
      }
      // tool_trace instrumentation - start
      const traceId = crypto.randomUUID();
      const traceStartTime = Date.now();
      const startedAtIso = new Date().toISOString();
      insertToolTrace(ctx.sql, {
        session_id: sessionId,
        chat_id: chatId,
        message_id: assistantMessageId,
        turn_number: turnNumber ?? 0,
        tool_name: tc.name,
        tool_input: tc.args as Record<string, unknown>,
      }).catch(() => {});
      ctx.publish(sessionId, {
        type: 'tool_trace_start',
        trace_id: traceId,
        message_id: assistantMessageId,
        chat_id: chatId,
        tool_name: tc.name,
        tool_input: tc.args as Record<string, unknown>,
        started_at: startedAtIso,
      });
      const tres = await executeToolCall(
        projectRoot, tc, session.allowed_read_paths,
        { sql: ctx.sql, sessionId, toolCallId: tc.id },
        ctx.hooks, sessionId,
      );
      // tool_trace instrumentation - finish
      const finishedAtIso = new Date().toISOString();
      const latencyMs = Date.now() - traceStartTime;
      updateToolTrace(ctx.sql, traceId, {
        finished_at: finishedAtIso,
        ...(tres.outcome === 'success' && tres.output != null ? { tool_output: JSON.stringify(tres.output) } : {}),
        latency_ms: latencyMs,
        outcome: tres.outcome,
        ...(tres.error ? { error: tres.error } : {}),
      }).catch(() => {});
      ctx.publish(sessionId, {
        type: 'tool_trace_finish',
        trace_id: traceId,
        message_id: assistantMessageId,
        chat_id: chatId,
        tool_name: tc.name,
        finished_at: finishedAtIso,
        outcome: tres.outcome,
        latency_ms: latencyMs,
        ...(tres.error ? { error: tres.error } : {}),
      });
      // vWhale: PostToolUse hook (best-effort, non-blocking).
      if (ctx.hooks) {
        ctx.hooks.run('PostToolUse', {
          event: 'PostToolUse',
          session_id: sessionId,
          tool_name: tc.name,
          tool_args: tc.args as Record<string, unknown>,
          tool_result: tres.output,
          tool_error: tres.error,
        }).catch(() => {});
      }
      // v#12 MistakeTracker: record the real execution outcome (success or a
      // FailureKind). This is the primary signal for heterogeneous-failure
      // detection.
      outcomes.push(tres.outcome);
      if (SYNTHESIS_TOOLS.has(tc.name)) {
        synthEntries.push({ tc, output: tres.output, ...(tres.error ? { error: tres.error } : {}) });
      }
      // v2.8: compute a compact unified diff for successful write-tool results.
      // The diff is derived from tool call args (old_string/new_string for
      // edit_file, content for create_file) and included in the WS frame so
      // the frontend can render a DiffSnippet inline. Not persisted to message_parts
      // (the args alone are enough to reproduce it on reload if needed).
      const toolDiff =
        !tres.error && tres.outcome === 'success' && isWriteTool(tc.name)
          ? diffFromToolArgs(tc.name, tc.args as Record<string, unknown>)
          : undefined;

      const stored = {
        tool_call_id: tc.id,
        output: tres.output,
        truncated: tres.truncated,
        ...(tres.error ? { error: tres.error } : {}),
      };
      // v1.13.20: parts-only write. Reads route through messages_with_parts.
      await insertParts(
        ctx.sql,
        partsFromToolMessage({ tool_results: stored }).map((p) => ({
          ...p,
          message_id: toolMessageId,
        })),
      );
      ctx.publish(sessionId, {
        type: 'tool_result',
        tool_message_id: toolMessageId,
        chat_id: chatId,
        tool_call_id: tc.id,
        output: tres.output,
        truncated: tres.truncated,
        ...(tres.error ? { error: tres.error } : {}),
        ...(toolDiff ? { diff: toolDiff } : {}),
      });
    })
  );

  if (pausingForUserInput) {
    ctx.publishUser({
      type: 'chat_status',
      chat_id: chatId,
      status: 'waiting_for_input',
      at: new Date().toISOString(),
    });
    ctx.log.info(
      { sessionId, chatId, assistantMessageId },
      'inference paused awaiting user input',
    );
    return {
      action: 'paused' as const,
      toolCallCount: toolCalls.length,
      toolCalls,
      nextAssistantId: null,
      outcomes,
    };
  }

  // v1.13.13: synthesis-pipeline branch. When any of this batch's tool calls
  // is a codecontext overview/analysis tool that produced a non-error result,
  // run a forced second-inference synthesis pass with auto-fetched files +
  // project docs instead of the normal recursive runAssistantTurn. Falls
  // through to the recursive call on synthesis failure (timeout, model
  // error). User-abort re-throws so the outer handler runs.
  const synthEntry = synthEntries.find((e) => !e.error && e.output != null);
  if (synthEntry) {
    // codecontext wrappers return { result: string, truncated: boolean, ... }.
    // Defensive: stringify the output if it isn't the expected shape so the
    // synthesis still has something to chew on rather than crashing on
    // missing `.result`.
    const out = synthEntry.output as { result?: unknown; truncated?: boolean; outputPath?: string };
    const toolResultText =
      typeof out?.result === 'string'
        ? out.result
        : JSON.stringify(synthEntry.output);
    // v1.13.15-b: forward the wrapper's truncation flag + opaque tmpfs id so
    // synthesisPipeline can re-read the full content for reference extraction.
    const ran = await runSynthesisPass({
      ctx,
      args,
      session,
      projectRoot,
      toolName: synthEntry.tc.name,
      toolResultText,
      ...(typeof out?.truncated === 'boolean' ? { truncated: out.truncated } : {}),
      ...(typeof out?.outputPath === 'string' ? { outputPath: out.outputPath } : {}),
    });
    if (ran) {
      return {
        action: 'synthesis_done' as const,
        toolCallCount: toolCalls.length,
        toolCalls,
        nextAssistantId: null,
        outcomes,
      };
    }
    // ran === false → synthesis failed (timeout / model error) → fall through
    // to the standard continue path below. The synth message (if created)
    // was already marked status='failed' inside runSynthesisPass.
  }

  // v1.14.0: create the next assistant row and return a continue result.
  // The caller (outer while loop in turn.ts) handles the iteration.
  const [nextAssistant] = await ctx.sql<{ id: string }[]>`
    INSERT INTO messages (session_id, chat_id, role, content, status, created_at)
    VALUES (${sessionId}, ${chatId}, 'assistant', '', 'streaming', clock_timestamp())
    RETURNING id
  `;
  return {
    action: 'continue' as const,
    toolCallCount: toolCalls.length,
    toolCalls,
    nextAssistantId: nextAssistant!.id,
    outcomes,
  };
}
