'use node';

import {
  Agent,
  listMessages,
  saveMessage,
  type MessageDoc,
} from '@convex-dev/agent';
import type { ModelMessage } from 'ai';
import type { FunctionHandle } from 'convex/server';
import { type ObjectType, v } from 'convex/values';
import { snakeCase } from 'lodash';

import { parseModelRef } from '../../../lib/shared/utils/model-ref';
import {
  isRecord,
  getString,
  narrowStringUnion,
} from '../../../lib/utils/type-guards';
import { components, internal } from '../../_generated/api';
import { internalAction, type ActionCtx } from '../../_generated/server';
import {
  createDelegationTool,
  buildDelegationInstructionsSection,
} from '../../agent_tools/delegation/create_delegation_tool';
import { loadDelegateAgents } from '../../agent_tools/delegation/load_delegation_agents';
import { createBoundIntegrationTool } from '../../agent_tools/integrations/create_bound_integration_tool';
import { fetchOperationsWithSchema } from '../../agent_tools/integrations/fetch_operations_summary';
import { createBoundMcpTool } from '../../agent_tools/mcp/create_bound_mcp_tool';
import { TOOL_NAMES, type ToolName } from '../../agent_tools/tool_names';
import { getToolRegistryMap } from '../../agent_tools/tool_registry';
import { createBoundWorkflowTool } from '../../agent_tools/workflows/create_bound_workflow_tool';
import { extractInputSchema } from '../../agent_tools/workflows/helpers/extract_input_schema';
import { resolveOrgSlug } from '../../organizations/resolve_org_slug';
import { recordFailure } from '../../providers/circuit_breaker';
import {
  isTransientProviderError,
  shouldFailoverToNextModel,
} from '../../providers/errors';
import { resolveLanguageModelWithFallback } from '../../providers/failover';
// Node-only fast-path resolver — in-process, skips the ~340ms runAction hop
// that resolve_model.ts uses to stay V8-importable.
import { resolveLanguageModelByIdNode } from '../../providers/resolve_model_node';
import { autoRouteReasonValidator } from '../../streaming/validators';
import { generateAgentResponse } from '../agent_response';
import { routeModelOrder } from '../agent_response/model_routing/route_model';
import { resolvePromptCaching } from '../agent_response/prompt_caching/strategy';
import type {
  GenerateResponseHooks,
  BeforeContextResult,
  BeforeGenerateResult,
} from '../agent_response/types';
import { userContextValidator } from '../agent_response/validators';
import { buildInlineMultiModalPrompt } from '../attachments/build_inline_multi_modal_prompt';
import {
  estimateTokens,
  DEFAULT_MODEL_CONTEXT_LIMIT,
  CONTEXT_SAFETY_MARGIN,
  SYSTEM_INSTRUCTIONS_TOKENS,
  OUTPUT_RESERVE,
} from '../context_management';
import {
  AGENT_CONTEXT_CONFIGS,
  type AgentType,
} from '../context_management/constants';
import { createAgentConfig } from '../create_agent_config';
import { createDebugLog } from '../debug_log';
import {
  NonRetryableError,
  classifyProviderError,
} from '../error_classification';
import { buildCallProviderOptions } from '../provider_options';
import { buildSkillContext } from './skills_runtime';

const debugLog = createDebugLog('DEBUG_CHAT_AGENT', '[runAgentGeneration]');

/**
 * Convex validator mirror of `responseTuningSchema` (lib/shared/schemas/agents).
 * Kept in lockstep so the serializable config can carry per-agent tuning
 * through the scheduler arg boundary.
 */
const responseTuningValidator = v.object({
  effort: v.optional(
    v.union(
      v.literal('adaptive'),
      v.literal('low'),
      v.literal('medium'),
      v.literal('high'),
    ),
  ),
  creativity: v.optional(
    v.union(
      v.literal('adaptive'),
      v.literal('precise'),
      v.literal('balanced'),
      v.literal('creative'),
    ),
  ),
  style: v.optional(
    v.union(
      v.literal('adaptive'),
      v.literal('concise'),
      v.literal('detailed'),
      v.literal('formal'),
      v.literal('friendly'),
    ),
  ),
  effortFloor: v.optional(
    v.union(
      v.literal('off'),
      v.literal('low'),
      v.literal('medium'),
      v.literal('high'),
    ),
  ),
  effortCeiling: v.optional(
    v.union(
      v.literal('off'),
      v.literal('low'),
      v.literal('medium'),
      v.literal('high'),
    ),
  ),
  budgetCaps: v.optional(
    v.object({
      easy: v.optional(v.number()),
      medium: v.optional(v.number()),
      hard: v.optional(v.number()),
    }),
  ),
  temperatureRange: v.optional(
    v.object({ min: v.optional(v.number()), max: v.optional(v.number()) }),
  ),
  verbosity: v.optional(
    v.union(
      v.literal('adaptive'),
      v.literal('terse'),
      v.literal('normal'),
      v.literal('verbose'),
    ),
  ),
  qualityProfile: v.optional(
    v.union(v.literal('lenient'), v.literal('balanced'), v.literal('strict')),
  ),
});

const serializableAgentConfigValidator = v.object({
  name: v.string(),
  /**
   * Root behavior the agent runs. Chat is the default; image-generation
   * is forked out before `runAgentGeneration` so this field is informational
   * here, but the mapper emits it and the validator is strict — keep it
   * declared so historical configs and image agents do not crash arg
   * validation.
   */
  primaryBehavior: v.optional(
    v.union(v.literal('chat'), v.literal('image-generation')),
  ),
  instructions: v.string(),
  convexToolNames: v.optional(v.array(v.string())),
  integrationBindings: v.optional(v.array(v.string())),
  workflowBindings: v.optional(v.array(v.string())),
  model: v.optional(v.string()),
  provider: v.optional(v.string()),
  maxSteps: v.optional(v.number()),
  outputFormat: v.optional(v.union(v.literal('text'), v.literal('json'))),
  knowledgeMode: v.optional(
    v.union(
      v.literal('off'),
      v.literal('tool'),
      v.literal('context'),
      v.literal('both'),
    ),
  ),
  webSearchMode: v.optional(
    v.union(
      v.literal('off'),
      v.literal('tool'),
      v.literal('context'),
      v.literal('both'),
    ),
  ),
  includeTeamKnowledge: v.optional(v.boolean()),
  includeOrgKnowledge: v.optional(v.boolean()),
  agentTeamId: v.optional(v.string()),
  agentTeamIds: v.optional(v.array(v.string())),
  knowledgeFileIds: v.optional(v.array(v.string())),
  /**
   * Projects feature: project IDs whose RAG-indexed files should be
   * unioned into the agent's file scope (chat happens inside a project).
   */
  agentProjectIds: v.optional(v.array(v.string())),
  delegateSlugs: v.optional(v.array(v.string())),
  skillBindings: v.optional(v.array(v.string())),
  structuredResponsesEnabled: v.optional(v.boolean()),
  timeoutMs: v.optional(v.number()),
  outputReserve: v.optional(v.number()),
  fallbackModels: v.optional(v.array(v.string())),
  personalizationMode: v.optional(v.union(v.literal('on'), v.literal('off'))),
  responseTuning: v.optional(responseTuningValidator),
  /** Advisory reply-language hint from the Auto router (feeds the language
   *  directive's fallback only). See `SerializableAgentConfig.replyLocaleHint`. */
  replyLocaleHint: v.optional(v.string()),
  routing: v.optional(
    v.object({
      modelSelection: v.optional(
        v.union(v.literal('config'), v.literal('auto')),
      ),
      cascade: v.optional(v.boolean()),
      cascadeDraftModel: v.optional(v.string()),
      // Orchestration policy lives on the router agent; included here so the
      // whole `routing` object validates when it rides along on any agent.
      orchestration: v.optional(
        v.union(
          v.literal('single'),
          v.literal('orchestrate'),
          v.literal('auto'),
        ),
      ),
      maxOrchestrationSteps: v.optional(v.number()),
    }),
  ),
});

const hooksConfigValidator = v.object({
  beforeContext: v.optional(v.string()),
  beforeGenerate: v.optional(v.string()),
  afterGenerate: v.optional(v.string()),
});

const runGenerationArgs = {
  agentType: v.string(),
  agentConfig: serializableAgentConfigValidator,
  model: v.string(),
  provider: v.optional(v.string()),
  debugTag: v.string(),
  enableStreaming: v.optional(v.boolean()),
  hooks: v.optional(hooksConfigValidator),
  threadId: v.string(),
  organizationId: v.string(),
  userId: v.optional(v.string()),
  agentSlug: v.optional(v.string()),
  autoRouteReason: v.optional(autoRouteReasonValidator),
  promptMessage: v.string(),
  /**
   * Un-augmented user text (without the attachment markdown that
   * `buildMessageWithAttachments` appends to `promptMessage`). Used as the
   * text part when building a multimodal prompt for vision-capable models,
   * so PDF/audio references aren't duplicated.
   */
  originalUserText: v.optional(v.string()),
  additionalContext: v.optional(v.record(v.string(), v.string())),
  userContext: v.optional(userContextValidator),
  parentThreadId: v.optional(v.string()),
  agentOptions: v.optional(v.any()),
  attachments: v.optional(
    v.array(
      v.object({
        fileId: v.id('_storage'),
        fileName: v.string(),
        fileType: v.string(),
        fileSize: v.number(),
      }),
    ),
  ),
  streamId: v.optional(v.string()),
  promptMessageId: v.optional(v.string()),
  maxSteps: v.optional(v.number()),
  deadlineMs: v.optional(v.number()),
  generationParams: v.optional(v.any()),
  maxContextTokens: v.optional(v.number()),
  threadTeamId: v.optional(v.string()),
  /**
   * Storage IDs of knowledge-base documents the user `@`-mentioned on this
   * turn (already access-validated by chatWithAgentTurn). When present, the
   * per-turn auto-RAG context is forced on and scoped to exactly these files
   * — see generate_response.ts.
   */
  pinnedFileIds: v.optional(v.array(v.string())),
  // Server-stamped turn-start (chatWithAgent entry) for TTFT measurement.
  // Optional so jobs scheduled before this field existed still validate
  // during a rolling deploy (the consumer falls back to the action start).
  requestStartMs: v.optional(v.number()),
  // PERF (diagnostic): wall-clock when startAgentChat scheduled this action.
  // Lets us isolate the scheduler dispatch + module-import hop in isolation.
  scheduledAtMs: v.optional(v.number()),
  /** Cache pre-warm: build the real tools + stable system prefix and issue a
   * single throwaway priming call, then return — no persistence/streaming. */
  prewarm: v.optional(v.boolean()),
};
type RunGenerationArgs = ObjectType<typeof runGenerationArgs>;

/**
 * Durable scheduled-action wrapper. Kept for callers that schedule generation
 * across the V8↔node boundary (fork/edit/openai/webhooks). The chat path calls
 * `runGenerationCore` IN-PROCESS instead (no node→backend runAction hop).
 */
export const runAgentGeneration = internalAction({
  args: runGenerationArgs,
  handler: (ctx, args) => runGenerationCore(ctx, args),
});

/**
 * Generation core: tool build → model resolution (fallback loop) →
 * generateAgentResponse → result/retry handling. Call this directly from a
 * node action to run generation in-process (the chat path); or via the
 * `runAgentGeneration` scheduled wrapper for durable cross-boundary callers.
 */
export async function runGenerationCore(
  ctx: ActionCtx,
  args: RunGenerationArgs,
) {
  const actionStartTime = Date.now();
  debugLog('ACTION_START', {
    threadId: args.threadId,
    timestamp: new Date(actionStartTime).toISOString(),
    // Send → this action's start: the upstream work (auto-route,
    // markGenerating, startChat) PLUS the Convex scheduler dequeue hop —
    // the single biggest pre-stream segment the pipeline can target.
    // Undefined for jobs scheduled before requestStartMs existed.
    sinceRequestStartMs: args.requestStartMs
      ? actionStartTime - args.requestStartMs
      : undefined,
    // PERF: pure scheduler dispatch + module-import latency. A large value
    // here that SHRINKS on the 2nd warm send = cold module re-import after a
    // `convex dev` push, not steady-state cost. Diagnostic.
    scheduleHopMs: args.scheduledAtMs
      ? actionStartTime - args.scheduledAtMs
      : undefined,
  });

  const {
    agentType: agentTypeStr,
    agentConfig,
    model,
    provider: _provider,
    debugTag,
    enableStreaming,
    hooks: hooksConfig,
    threadId,
    organizationId,
    userId,
    promptMessage,
    originalUserText,
    additionalContext,
    userContext,
    parentThreadId,
    agentOptions,
    attachments,
    streamId,
    promptMessageId,
    maxSteps,
    deadlineMs,
    generationParams,
    maxContextTokens,
  } = args;

  const agentType = narrowStringUnion(
    agentTypeStr,
    // oxlint-disable-next-line typescript/no-unsafe-type-assertion -- Object.keys loses literal types; keys are known AgentType values
    Object.keys(AGENT_CONTEXT_CONFIGS) as AgentType[],
  );
  if (!agentType) {
    throw new Error(`Invalid agent type: ${agentTypeStr}`);
  }

  try {
    const toolBuildStart = Date.now();

    // Flatten the tool-build round-trips into ONE awaited set instead of a
    // serial resolveOrgSlug + two stage barriers. The org-id-only resources
    // (locale, governance, MCP, integrations) start immediately; the
    // orgSlug/orgLocale-dependent builders (skill, delegation, workflow)
    // chain off those promises so they begin the moment their input resolves.
    // `buildSkillContext` short-circuits to the empty snapshot when the
    // agent's `skillBindings` allowlist is empty (no disk reads, no
    // `expand_skill` tool). Arg passing is byte-identical to the prior two
    // stages — only the concurrency shape changed.
    const effectiveConfig = agentConfig;
    const orgSlugPromise = resolveOrgSlug(ctx, organizationId);
    const orgLocalePromise = ctx.runQuery(
      internal.organizations.internal_queries.getOrganizationDefaultLocale,
      { organizationId },
    );
    const [
      governanceResult,
      mcpExtraTools,
      integrationExtraTools,
      skillSnapshot,
      delegationResult,
      workflowExtraTools,
    ] = await Promise.all([
      fetchGovernanceSystemPrompt(ctx, organizationId, parentThreadId),
      buildMcpTools(ctx, organizationId),
      buildIntegrationTools(ctx, effectiveConfig, organizationId),
      // orgSlug/orgLocale are awaited transitively via these dependent
      // builders (so resolveOrgSlug errors still surface); not needed
      // standalone after the block.
      orgSlugPromise.then((s) =>
        buildSkillContext(ctx, s, agentConfig.skillBindings),
      ),
      Promise.all([orgSlugPromise, orgLocalePromise]).then(([s, l]) =>
        buildDelegationTools(ctx, effectiveConfig, organizationId, s, l),
      ),
      orgSlugPromise.then((s) => buildWorkflowTools(ctx, effectiveConfig, s)),
    ]);

    // Extract delegation tools and instructions append
    let delegationExtraTools: Record<string, unknown> | undefined;
    let delegationInstructionsAppend = '';
    if (delegationResult) {
      delegationExtraTools = delegationResult.tools;
      delegationInstructionsAppend = delegationResult.instructionsAppend;
      debugLog('Built delegation tools', {
        names: Object.keys(delegationExtraTools),
      });
    }

    if (workflowExtraTools) {
      debugLog('Built bound workflow tools', {
        names: Object.keys(workflowExtraTools),
      });
    }

    // Extract governance prompt prefixes/suffixes
    const { mandatoryPrefix, mandatorySuffix } = governanceResult;

    const toolBuildMs = Date.now() - toolBuildStart;
    debugLog('PERF_TOOL_BUILD', {
      durationMs: toolBuildMs,
      hasIntegrations: !!integrationExtraTools,
      hasDelegation: !!delegationExtraTools,
      hasWorkflows: !!workflowExtraTools,
      hasMcp: !!mcpExtraTools,
      hasGovernance: !!(mandatoryPrefix || mandatorySuffix),
    });

    // Merge all extra tools. Skill built-ins (`expand_skill`,
    // `read_skill_file`) are spliced in last so they cannot be shadowed by
    // an integration / workflow / delegation tool sharing the same
    // reserved name — defense-in-depth for the reserved-name set.
    const hasAnyExtra =
      integrationExtraTools ||
      delegationExtraTools ||
      workflowExtraTools ||
      mcpExtraTools ||
      Object.keys(skillSnapshot.builtInTools).length > 0;
    const allExtraTools: Record<string, unknown> | undefined = hasAnyExtra
      ? {
          ...integrationExtraTools,
          ...delegationExtraTools,
          ...workflowExtraTools,
          ...mcpExtraTools,
          ...skillSnapshot.builtInTools,
        }
      : undefined;

    // Combine instructions with delegation agent descriptions
    let finalInstructions = delegationInstructionsAppend
      ? agentConfig.instructions + delegationInstructionsAppend
      : agentConfig.instructions;

    // Wrap with mandatory governance system prompt (non-overridable)
    if (mandatoryPrefix) {
      finalInstructions = mandatoryPrefix + '\n\n' + finalInstructions;
    }
    if (mandatorySuffix) {
      finalInstructions = finalInstructions + '\n\n' + mandatorySuffix;
    }
    // Append the "Available Skills" suffix AFTER governance so the
    // governance/persona prefix stays cache-stable across skill edits
    // (plan Phase 5 ordering). Empty string when no skills bound.
    if (skillSnapshot.systemPromptAppend) {
      finalInstructions = finalInstructions + skillSnapshot.systemPromptAppend;
    }

    // Build hooks object from FunctionHandle strings
    const hooks: GenerateResponseHooks | undefined = hooksConfig
      ? buildHooksFromConfig(hooksConfig)
      : undefined;

    // Build tools summary for context window display.
    // Read from effectiveConfig — agentConfig predates the skill merge
    // and would omit skill-declared convex tools.
    const toolsSummary = buildToolsSummary(
      effectiveConfig.convexToolNames,
      allExtraTools,
    );

    // Build ordered list of models to try: primary + fallbacks.
    const primaryModelId = model === 'default' ? undefined : model;
    let modelsToTry: Array<string | undefined> = [
      primaryModelId,
      ...(agentConfig.fallbackModels ?? []),
    ];

    // Complexity-based model routing (opt-in per agent): reorder the concrete
    // refs so the tier-appropriate model is tried first. Skips the `default`-tag
    // entry (undefined) and prewarm; fails safe to config order.
    if (
      agentConfig.routing?.modelSelection === 'auto' &&
      !args.prewarm &&
      primaryModelId
    ) {
      const concreteRefs = modelsToTry.filter(
        (r): r is string => typeof r === 'string',
      );
      if (concreteRefs.length > 1) {
        modelsToTry = await routeModelOrder(ctx, {
          organizationId,
          supportedModels: concreteRefs,
          promptText: promptMessage,
          // Only IMAGE attachments require a vision-capable model. A
          // PDF/CSV/text attachment must not narrow the routing pool to vision
          // models (and away from the strongest text model) — mirror the
          // `image/*` filter used for the multimodal branch below.
          requiresVision:
            attachments?.some((a) => a.fileType.startsWith('image/')) ?? false,
        });
      }
    }

    // Fallback retry loop — try each model in order until one succeeds
    let lastFallbackError: unknown;
    for (let attempt = 0; attempt < modelsToTry.length; attempt++) {
      const currentModelId = modelsToTry[attempt];

      // RESOLUTION (no HTTP). A model/provider that is not configured or
      // wrongly configured — missing/unknown provider or model, unresolvable
      // API key, malformed baseURL — throws synchronously here. When that
      // happens we go straight to the next model in line WITHOUT making (and
      // waiting on) a doomed request, and without recording a circuit-breaker
      // failure (it's a config problem, not provider flakiness).
      // orgSlug was resolved once in the outer Promise.all and is shared across
      // model lookup + delegation + workflows so multi-org deployments read each
      // org's own provider/API-key files. Per-entry provider qualifier (e.g.
      // "openrouter:foo") takes precedence over the agent's.
      let resolved: Awaited<ReturnType<typeof resolveLanguageModelByIdNode>>;
      try {
        const parsed = currentModelId
          ? parseModelRef(currentModelId)
          : undefined;
        resolved = parsed
          ? await resolveLanguageModelByIdNode(ctx, {
              modelId: parsed.modelId,
              providerName: parsed.providerName ?? agentConfig.provider,
              organizationId,
            })
          : await resolveLanguageModelWithFallback(ctx, {
              providerName: agentConfig.provider,
              tag: 'chat',
              organizationId,
            });
      } catch (resolveError) {
        lastFallbackError = resolveError;
        if (attempt < modelsToTry.length - 1) {
          debugLog('SKIP_UNCONFIGURED_MODEL', {
            model: currentModelId ?? 'default',
            nextModel: modelsToTry[attempt + 1] ?? 'default',
            reason:
              resolveError instanceof Error
                ? resolveError.message
                : String(resolveError),
          });
          continue;
        }
        throw resolveError;
      }

      try {
        const { languageModel, modelData } = resolved;
        const resolvedProvider = modelData.providerName;
        const resolvedModelId = modelData.modelId;

        // Prewarm only pays off when there is a cache to warm. For a model whose
        // caching strategy resolves to 'none' (unknown family, no operator
        // override), the priming generation would write nothing and hit nothing
        // on the real turn — a wasted billed input-token call on every composer
        // focus. Skip it. 'auto-server' (OpenAI/DeepSeek) and
        // 'explicit-breakpoints' (Anthropic/Gemini) still warm.
        if (
          args.prewarm &&
          resolvePromptCaching({
            modelId: resolvedModelId,
            promptCaching: modelData.promptCaching,
          }).mode === 'none'
        ) {
          debugLog('PREWARM_SKIP_NO_CACHE', { model: resolvedModelId });
          return {
            threadId,
            text: '',
            finishReason: 'prewarm-skip',
            durationMs: 0,
          };
        }

        // Vision branch: when the resolved chat model has the `vision`
        // tag and the turn carries image attachments, inline the images
        // as multimodal content and drop the `image` tool for this
        // attempt. Failover to a non-vision model on the next attempt
        // re-evaluates and reverts to the markdown + image-tool path.
        const imageAttachments =
          attachments?.filter((a) => a.fileType.startsWith('image/')) ?? [];
        const isVisionCapable = modelData.tags.includes('vision');
        const useMultiModal = isVisionCapable && imageAttachments.length > 0;

        let multiModalPrompt: ModelMessage[] | undefined;
        if (useMultiModal) {
          const built = await buildInlineMultiModalPrompt(ctx, {
            userText: originalUserText ?? promptMessage,
            imageAttachments,
          });
          multiModalPrompt = built.prompt;
          debugLog('MULTIMODAL_BRANCH', {
            modelId: resolvedModelId,
            inlinedImageCount: built.inlinedImageCount,
            skippedImages: built.skippedImages,
          });
        }

        // Read+write symmetry: only attach `propose_memory` when the
        // memories gate is on (org `user_memories` policy,
        // `prefs.memoriesEnabled === true`, and no thread veto). The
        // agent-level `personalizationMode === 'off'` short-circuits
        // before we hit the DB and disables BOTH features.
        const personalizationActive =
          userId && organizationId && agentConfig.personalizationMode !== 'off'
            ? ((
                await ctx.runQuery(
                  internal.personalization.internal_queries
                    .isPersonalizationActiveForChat,
                  { userId, organizationId, threadId },
                )
              )?.memories ?? false)
            : false;

        // Create agent factory function from serializable config
        const createAgent = () => {
          // Filter tools: exclude rag_search/web when their retrieval mode
          // is 'context' or 'off' (tool should only be available in 'tool'/'both').
          // Drop `image` when the chat model handles images natively.
          const knowledgeMode = agentConfig.knowledgeMode ?? 'off';
          const webSearchMode = agentConfig.webSearchMode ?? 'off';
          // Read from effectiveConfig so skill-declared convex tools
          // (post-mergeSkillDependencies) actually reach the LLM.
          const baseToolList = effectiveConfig.convexToolNames ?? [];
          const autoInjected: string[] = [...baseToolList];
          // `propose_memory` is offered only while personalization is active.
          if (
            personalizationActive &&
            !autoInjected.includes('propose_memory')
          ) {
            autoInjected.push('propose_memory');
          }
          // `generate_image` is always available to chat agents so any
          // assistant — including whichever one the Auto router picks — can
          // satisfy an explicit "create an image" request. It degrades
          // gracefully (the tool reports unavailable) when the workspace has no
          // image-generation model configured. Image-generation agents never
          // reach this path (they bypass the tool loop), but guard anyway.
          if (
            agentConfig.primaryBehavior !== 'image-generation' &&
            !autoInjected.includes('generate_image')
          ) {
            autoInjected.push('generate_image');
          }
          const filteredToolNames = autoInjected.filter((n): n is ToolName => {
            if (!(TOOL_NAMES as readonly string[]).includes(n)) return false;
            if (n === 'propose_memory' && !personalizationActive) return false;
            if (
              n === 'rag_search' &&
              knowledgeMode !== 'tool' &&
              knowledgeMode !== 'both'
            )
              return false;
            if (
              n === 'web' &&
              webSearchMode !== 'tool' &&
              webSearchMode !== 'both'
            )
              return false;
            if (n === 'image' && useMultiModal) return false;
            return true;
          });
          const config = createAgentConfig({
            name: agentConfig.name,
            instructions: finalInstructions,
            languageModel,
            modelMaxOutputTokens: modelData.maxOutputTokens,
            convexToolNames:
              filteredToolNames.length > 0 ? filteredToolNames : undefined,
            extraTools: allExtraTools,
            maxSteps: agentConfig.maxSteps,
          });
          return new Agent(components.agent, config);
        };

        // PERF: ms from this action's entry to the generateAgentResponse
        // hand-off — i.e. the tool-build + model-resolution setup inside
        // runAgentGeneration. Diagnostic.
        debugLog('PERF_RUNGEN_SETUP', {
          threadId: args.threadId,
          runAgentGenSetupMs: Date.now() - actionStartTime,
          sinceSendMs: args.requestStartMs
            ? Date.now() - args.requestStartMs
            : undefined,
        });

        const result = await generateAgentResponse(
          {
            agentType,
            createAgent,
            model: resolvedModelId,
            provider: resolvedProvider,
            debugTag,
            enableStreaming,
            hooks,
            convexToolNames: effectiveConfig.convexToolNames,
            knowledgeMode: agentConfig.knowledgeMode,
            webSearchMode: agentConfig.webSearchMode,
            includeTeamKnowledge: agentConfig.includeTeamKnowledge,
            includeOrgKnowledge: agentConfig.includeOrgKnowledge,
            agentTeamId: agentConfig.agentTeamId,
            agentTeamIds: agentConfig.agentTeamIds,
            knowledgeFileIds: agentConfig.knowledgeFileIds,
            agentProjectIds: agentConfig.agentProjectIds,
            structuredResponsesEnabled: agentConfig.structuredResponsesEnabled,
            maxContextTokens,
            instructions: finalInstructions,
            toolsSummary,
            personalizationMode: agentConfig.personalizationMode,
            providerOptions: buildCallProviderOptions(modelData),
            modelMaxOutputTokens: modelData.maxOutputTokens,
            modelContextWindow: modelData.contextWindow,
            reasoningCapability: modelData.reasoning,
            responseTuning: agentConfig.responseTuning,
            replyLocaleHint: agentConfig.replyLocaleHint,
          },
          {
            ctx,
            threadId,
            organizationId,
            userId,
            agentSlug: args.agentSlug,
            autoRouteReason: args.autoRouteReason,
            teamIds: args.threadTeamId ? [args.threadTeamId] : undefined,
            providerCost:
              modelData.inputCentsPerMillion != null
                ? {
                    inputCentsPerMillion: modelData.inputCentsPerMillion,
                    outputCentsPerMillion: modelData.outputCentsPerMillion ?? 0,
                  }
                : undefined,
            promptMessage,
            additionalContext,
            userContext,
            parentThreadId,
            agentOptions,
            attachments,
            multiModalPrompt,
            streamId,
            promptMessageId,
            maxSteps,
            deadlineMs,
            generationParams,
            pinnedFileIds: args.pinnedFileIds,
            requestStartMs: args.requestStartMs,
            // Suppress error cleanup (stream error, generation status clear,
            // failed message) when there are more fallback models to try.
            // The fallback loop handles cleanup itself.
            suppressErrorCleanup: attempt < modelsToTry.length - 1,
            prewarm: args.prewarm,
          },
        );

        // Prewarm: the priming call is done (cache written). Return before the
        // empty-text guard below — a prewarm result is intentionally empty.
        if (args.prewarm) {
          return result;
        }

        // User cancelled — cancelGeneration already handled message state
        if (result.finishReason === 'cancelled') {
          return result;
        }

        // Validate response — save a failed message so the client exits loading
        if (!result.text?.trim()) {
          try {
            await saveMessage(ctx, components.agent, {
              threadId,
              message: {
                role: 'assistant',
                content:
                  'I was unable to generate a response. Please try again.',
              },
              metadata: {
                status: 'failed',
                error: 'Agent returned empty response',
              },
            });
          } catch (saveError) {
            console.error(
              '[runAgentGeneration] Failed to save failed message:',
              saveError,
            );
          }
          throw new Error(
            `Agent returned empty response: ${JSON.stringify({
              model: result.model,
              usage: result.usage,
            })}`,
          );
        }

        return result;
      } catch (fallbackError) {
        lastFallbackError = fallbackError;
        const hasMoreFallbacks = attempt < modelsToTry.length - 1;

        // Retry on any provider-specific error with remaining fallbacks.
        // This includes transient errors (429, 5xx) AND non-transient
        // provider errors (401 auth, 404 model-not-found) because a
        // different fallback model may use a different provider.
        if (hasMoreFallbacks && shouldFailoverToNextModel(fallbackError)) {
          const failedModelLabel = currentModelId ?? model;
          const nextModel = modelsToTry[attempt + 1] ?? 'default';

          // Record circuit breaker failure only for transient errors
          // (429, 5xx, timeouts). Non-transient errors like 401/404 are
          // config issues, not provider flakiness.
          if (
            currentModelId &&
            agentConfig.provider &&
            isTransientProviderError(fallbackError)
          ) {
            recordFailure(agentConfig.provider, currentModelId);
          }

          // Check remaining deadline budget before retrying
          if (deadlineMs && Date.now() >= deadlineMs - 5000) {
            debugLog('FALLBACK_SKIP_DEADLINE', {
              failedModel: failedModelLabel,
              remainingMs: deadlineMs - Date.now(),
            });
            throw fallbackError;
          }

          const errStatus = isRecord(fallbackError)
            ? (fallbackError['status'] ?? fallbackError['statusCode'])
            : undefined;
          const errMessage = isRecord(fallbackError)
            ? getString(fallbackError, 'message')
            : undefined;
          debugLog('MODEL_FALLBACK', {
            attempt: attempt + 1,
            failedModel: failedModelLabel,
            nextModel,
            errorStatus: typeof errStatus === 'number' ? errStatus : undefined,
            errorMessage: errMessage?.slice(0, 200),
          });

          // Save system message so the user sees the fallback in chat
          try {
            await saveMessage(ctx, components.agent, {
              threadId,
              message: {
                role: 'system',
                content: `[MODEL_FALLBACK] ${failedModelLabel} was unavailable. Trying ${nextModel}...`,
              },
            });
          } catch (msgError) {
            console.error(
              '[runAgentGeneration] Failed to save fallback message:',
              msgError,
            );
          }

          // Clean up stale assistant messages from this attempt.
          // With suppressErrorCleanup, generateAgentResponse skips saving
          // failed messages, but the Agent SDK may have created a pending
          // message before the error. Convert any failed/pending assistant
          // messages to a system fallback note.
          try {
            const msgs = await listMessages(ctx, components.agent, {
              threadId,
              paginationOpts: { cursor: null, numItems: 5 },
              excludeToolMessages: true,
            });
            const staleAssistants = msgs.page.filter(
              (m: MessageDoc) =>
                m.message?.role === 'assistant' &&
                (m.status === 'failed' || m.status === 'pending'),
            );
            for (const stale of staleAssistants) {
              await ctx.runMutation(components.agent.messages.updateMessage, {
                messageId: stale._id,
                patch: {
                  status: 'success',
                  message: {
                    role: 'system',
                    content: `[MODEL_FALLBACK] ${failedModelLabel} failed — retrying with ${nextModel}.`,
                  },
                },
              });
            }
          } catch (cleanupError) {
            debugLog('FALLBACK_CLEANUP_ERROR', { error: cleanupError });
          }

          continue;
        }

        // Non-transient error or no more fallbacks — rethrow
        throw fallbackError;
      }
    }

    // Should not reach here, but satisfy TypeScript
    throw lastFallbackError ?? new Error('No model could be resolved');
  } catch (error) {
    // Log full error details for debugging
    const err = isRecord(error) ? error : { message: String(error) };
    console.error('[runAgentGeneration] Full error details:', {
      name: getString(err, 'name'),
      message: getString(err, 'message'),
      code: getString(err, 'code'),
      status: err['status'],
      statusCode: err['statusCode'],
      cause: err['cause'],
      stack: getString(err, 'stack'),
      error: JSON.stringify(
        error,
        isRecord(error) ? Object.getOwnPropertyNames(error) : [],
        2,
      ),
    });

    // generateAgentResponse's own catch terminalizes the stream for failures
    // raised inside it. Failures BEFORE it runs (tool building, model
    // resolution) reach here with the stream still 'pending'/'streaming',
    // which would leave non-streaming pollers (e.g. the Slack processor)
    // hanging until their own timeout. Mark it errored here too — idempotent
    // if it was already terminalized.
    if (streamId) {
      try {
        await ctx.runMutation(
          internal.streaming.internal_mutations.errorStream,
          { streamId },
        );
      } catch (streamError) {
        console.error(
          '[runAgentGeneration] Failed to error stream:',
          streamError,
        );
      }
      // Clear generation status so the UI stops showing "Thinking..."
      try {
        await ctx.runMutation(
          internal.threads.internal_mutations.clearGenerationStatus,
          { threadId, streamId },
        );
      } catch (clearError) {
        console.error(
          '[runAgentGeneration] Failed to clear generation status:',
          clearError,
        );
      }
    }

    // Prewarm is contractually invisible (no spinner, no saved message). A
    // failure BEFORE generateAgentResponse runs (model resolution, tool
    // building) still reaches this catch-all, so the failed-message save must be
    // skipped for prewarm — otherwise a misconfigured model would inject a
    // spurious "failed" assistant bubble into the user's real thread on every
    // composer focus. The error is still logged above and rethrown below.
    if (!args.prewarm) {
      try {
        const msgs = await listMessages(ctx, components.agent, {
          threadId,
          paginationOpts: { cursor: null, numItems: 5 },
          excludeToolMessages: true,
        });
        const newestAssistant = msgs.page.find(
          (m: MessageDoc) => m.message?.role === 'assistant',
        );
        const hasFailedAssistant = newestAssistant?.status === 'failed';
        if (!hasFailedAssistant) {
          const providerError = classifyProviderError(error);
          await saveMessage(ctx, components.agent, {
            threadId,
            message: {
              role: 'assistant',
              content: providerError.userMessage,
            },
            metadata: {
              status: 'failed',
              error: `[${providerError.errorType}] ${getString(err, 'message') ?? 'Unknown error'}`,
            },
          });
        }
      } catch (saveError) {
        console.error(
          '[runAgentGeneration] Failed to save failed message:',
          saveError,
        );
      }
    }

    throw new NonRetryableError(
      `Agent generation failed: ${JSON.stringify({
        message: getString(err, 'message'),
        code: getString(err, 'code'),
        status: err['status'],
        cause: err['cause'],
      })}`,
      error,
      'generation_error',
    );
  }
}

// ---------------------------------------------------------------------------
// T2 helper functions: parallelized tool building
// ---------------------------------------------------------------------------

interface AgentConfigForTools {
  integrationBindings?: string[];
  delegateSlugs?: string[];
  workflowBindings?: string[];
}

/**
 * Build bound integration tools for all configured integration bindings.
 */
async function buildIntegrationTools(
  ctx: ActionCtx,
  agentConfig: AgentConfigForTools,
  organizationId: string,
): Promise<Record<string, unknown> | undefined> {
  if (!agentConfig.integrationBindings?.length) return undefined;

  const results = await Promise.all(
    agentConfig.integrationBindings.map(async (name) => {
      const fetched = await fetchOperationsWithSchema(
        ctx,
        organizationId,
        name,
      );
      return {
        key: `integration_${name}`,
        tool: createBoundIntegrationTool(
          name,
          fetched?.summary,
          fetched?.operations,
          fetched?.metadata,
        ),
      };
    }),
  );

  const tools: Record<string, unknown> = {};
  for (const { key, tool } of results) {
    tools[key] = tool;
  }
  return tools;
}

/**
 * Build delegation tools for all configured delegate slugs.
 *
 * `orgSlug` and `orgLocale` are resolved once by the caller (hoisted into
 * the outer Promise.all) so they can be shared with sibling builders —
 * notably workflows, which also need the real orgSlug for multi-tenant
 * filesystem lookups. Delegate systemInstructions and the appended scaffold
 * text both resolve against `orgLocale` so parent + delegates speak the
 * same language.
 */
async function buildDelegationTools(
  ctx: ActionCtx,
  agentConfig: AgentConfigForTools,
  organizationId: string,
  orgSlug: string,
  orgLocale: string,
): Promise<
  | {
      tools: Record<string, unknown>;
      instructionsAppend: string;
    }
  | undefined
> {
  if (!agentConfig.delegateSlugs?.length) return undefined;

  const delegates = await loadDelegateAgents(
    ctx,
    agentConfig.delegateSlugs,
    organizationId,
    orgSlug,
    orgLocale,
  );

  if (delegates.length === 0) return undefined;

  const tools: Record<string, unknown> = {};
  for (const delegate of delegates) {
    const delegationTool = createDelegationTool(delegate);
    tools[delegationTool.name] = delegationTool.tool;
  }

  return {
    tools,
    instructionsAppend: buildDelegationInstructionsSection(
      delegates,
      orgLocale,
    ),
  };
}

/**
 * Build bound workflow tools for all configured workflow bindings.
 */
async function buildWorkflowTools(
  ctx: ActionCtx,
  agentConfig: AgentConfigForTools,
  orgSlug: string,
): Promise<Record<string, unknown> | undefined> {
  if (!agentConfig.workflowBindings?.length) return undefined;

  const results = await Promise.all(
    agentConfig.workflowBindings.map(async (slug) => {
      const result: unknown = await ctx.runAction(
        internal.workflows.file_actions.readWorkflowForExecution,
        { orgSlug, workflowSlug: slug },
      );

      if (!isRecord(result) || result.ok !== true) {
        return null;
      }

      // oxlint-disable-next-line typescript/no-unsafe-type-assertion -- readWorkflowForExecution returns v.any() but ok=true guarantees WorkflowJsonConfig shape
      const config = result.config as {
        name: string;
        description?: string;
        steps: Array<{ stepType: string; config?: unknown }>;
      };

      const startStep = config.steps.find((s) => s.stepType === 'start');
      const inputSchema = extractInputSchema(startStep?.config);

      const toolKey = `workflow_${snakeCase(slug)}`;
      return {
        key: toolKey,
        tool: createBoundWorkflowTool(
          {
            workflowSlug: slug,
            name: config.name,
            description: config.description,
          },
          inputSchema,
        ),
      };
    }),
  );

  const tools: Record<string, unknown> = {};
  for (const entry of results) {
    if (entry) {
      tools[entry.key] = entry.tool;
    }
  }

  return Object.keys(tools).length > 0 ? tools : undefined;
}

/**
 * Fetch the mandatory system prompt governance policy.
 * Skipped for sub-agents to prevent double-injection in delegation chains.
 */
async function fetchGovernanceSystemPrompt(
  ctx: ActionCtx,
  organizationId: string,
  parentThreadId: string | undefined,
): Promise<{ mandatoryPrefix: string; mandatorySuffix: string }> {
  if (parentThreadId) {
    return { mandatoryPrefix: '', mandatorySuffix: '' };
  }

  const systemPromptPolicy = await ctx.runQuery(
    internal.governance.internal_queries.getSystemPromptPolicyInternal,
    { organizationId },
  );

  let mandatoryPrefix = '';
  let mandatorySuffix = '';

  if (
    systemPromptPolicy?.enabled !== false &&
    isRecord(systemPromptPolicy?.config)
  ) {
    const cfg = systemPromptPolicy.config;
    if (
      typeof cfg.mandatoryPrefixPrompt === 'string' &&
      cfg.mandatoryPrefixPrompt.trim()
    ) {
      mandatoryPrefix = cfg.mandatoryPrefixPrompt.trim();
    }
    if (
      typeof cfg.mandatorySuffixPrompt === 'string' &&
      cfg.mandatorySuffixPrompt.trim()
    ) {
      mandatorySuffix = cfg.mandatorySuffixPrompt.trim();
    }
  }

  return { mandatoryPrefix, mandatorySuffix };
}

/**
 * Build bound MCP server tools from all active MCP servers for the org.
 */
async function buildMcpTools(
  ctx: ActionCtx,
  organizationId: string,
): Promise<Record<string, unknown> | undefined> {
  interface ActiveMcpServer {
    _id: string;
    name: string;
    displayName: string;
    discoveredTools?: Array<{
      name: string;
      description?: string;
      inputSchema?: Record<string, unknown>;
      requiresApproval?: boolean;
    }>;
  }

  const activeServers: ActiveMcpServer[] = await ctx.runQuery(
    internal.mcp_servers.internal_queries.listActiveByOrg,
    { organizationId },
  );

  if (activeServers.length === 0) return undefined;

  const tools: Record<string, unknown> = {};
  for (const server of activeServers) {
    if (!server.discoveredTools?.length) continue;
    for (const tool of server.discoveredTools) {
      const toolKey = `mcp_${server.name}_${tool.name}`;
      tools[toolKey] = createBoundMcpTool(server._id, server.displayName, tool);
    }
  }

  if (Object.keys(tools).length > 0) {
    debugLog('Built bound MCP tools', { names: Object.keys(tools) });
    return tools;
  }
  return undefined;
}

/**
 * Build hooks object from FunctionHandle configuration.
 * Converts string handles to callable functions.
 */
function buildHooksFromConfig(hooksConfig: {
  beforeContext?: string;
  beforeGenerate?: string;
  afterGenerate?: string;
}): GenerateResponseHooks {
  const hooks: GenerateResponseHooks = {};

  if (hooksConfig.beforeContext) {
    const handle =
      // oxlint-disable-next-line typescript/no-unsafe-type-assertion -- Convex stores FunctionHandle as string; branded type requires assertion
      hooksConfig.beforeContext as unknown as FunctionHandle<'action'>;
    hooks.beforeContext = async (ctx, args) => {
      const result = await ctx.runAction(handle, {
        threadId: args.threadId,
        userId: args.userId,
        promptMessage: args.promptMessage,
        organizationId: args.organizationId,
      });
      // runAction returns unknown; we trust the hook contract
      // oxlint-disable-next-line typescript/no-unsafe-type-assertion -- hook return type guaranteed by contract
      return result as BeforeContextResult;
    };
  }

  if (hooksConfig.beforeGenerate) {
    const handle =
      // oxlint-disable-next-line typescript/no-unsafe-type-assertion -- Convex stores FunctionHandle as string; branded type requires assertion
      hooksConfig.beforeGenerate as unknown as FunctionHandle<'action'>;
    hooks.beforeGenerate = async (ctx, args, context, _hookData) => {
      const result = await ctx.runAction(handle, {
        threadId: args.threadId,
        promptMessage: args.promptMessage,
        attachments: args.attachments,
        contextMessagesTokens: context.stats.totalTokens,
      });
      // oxlint-disable-next-line typescript/no-unsafe-type-assertion -- hook return type guaranteed by contract
      return result as BeforeGenerateResult;
    };
  }

  if (hooksConfig.afterGenerate) {
    const handle =
      // oxlint-disable-next-line typescript/no-unsafe-type-assertion -- Convex stores FunctionHandle as string; branded type requires assertion
      hooksConfig.afterGenerate as unknown as FunctionHandle<'action'>;
    hooks.afterGenerate = async (ctx, args, result, _hookData) => {
      await ctx.runAction(handle, {
        threadId: args.threadId,
        result: {
          text: result.text,
          usage: result.usage,
          durationMs: result.durationMs,
        },
      });
    };
  }

  return hooks;
}

/**
 * Extract a tool description from a createTool() result.
 */
function getToolDescription(tool: unknown): string | undefined {
  if (isRecord(tool) && typeof tool['description'] === 'string') {
    return tool['description'];
  }
  return undefined;
}

/**
 * Build a formatted summary of all tools available to the agent.
 * Used for context window display only — not sent to the LLM.
 */
function buildToolsSummary(
  convexToolNames: string[] | undefined,
  integrationExtraTools: Record<string, unknown> | undefined,
): string | undefined {
  const entries: string[] = [];

  // Registry tools
  if (convexToolNames?.length) {
    const registry = getToolRegistryMap();
    for (const name of convexToolNames) {
      const validName = narrowStringUnion(name, TOOL_NAMES);
      const toolDef = validName ? registry[validName] : undefined;
      if (toolDef) {
        const description = getToolDescription(toolDef.tool);
        entries.push(
          description ? `### ${name}\n${description}` : `### ${name}`,
        );
      } else {
        entries.push(`### ${name}`);
      }
    }
  }

  // Integration-bound tools
  if (integrationExtraTools) {
    for (const [name, tool] of Object.entries(integrationExtraTools)) {
      const description = getToolDescription(tool);
      entries.push(description ? `### ${name}\n${description}` : `### ${name}`);
    }
  }

  if (entries.length === 0) {
    return undefined;
  }

  return entries.join('\n\n');
}

const beforeGenerateDebugLog = createDebugLog(
  'DEBUG_CHAT_AGENT',
  '[beforeGenerateHook]',
);

export const beforeGenerateHook = internalAction({
  args: {
    threadId: v.string(),
    promptMessage: v.string(),
    attachments: v.optional(
      v.array(
        v.object({
          fileId: v.id('_storage'),
          fileName: v.string(),
          fileType: v.string(),
          fileSize: v.number(),
        }),
      ),
    ),
    contextMessagesTokens: v.number(),
  },
  returns: v.object({
    promptContent: v.optional(v.any()),
    contextExceedsBudget: v.boolean(),
  }),
  handler: async (ctx, args) => {
    const { threadId, promptMessage, contextMessagesTokens } = args;

    const currentPromptTokens = estimateTokens(promptMessage || '');
    const contextBudget =
      DEFAULT_MODEL_CONTEXT_LIMIT * CONTEXT_SAFETY_MARGIN -
      SYSTEM_INSTRUCTIONS_TOKENS -
      currentPromptTokens -
      OUTPUT_RESERVE;

    const contextExceedsBudget = contextMessagesTokens > contextBudget;
    if (contextExceedsBudget) {
      beforeGenerateDebugLog('Context may exceed budget', {
        threadId,
        budget: contextBudget,
        contextTokens: contextMessagesTokens,
      });
    }

    const promptContent = await buildTodosPromptAugmentation(
      ctx,
      threadId,
      promptMessage,
    );

    return { promptContent, contextExceedsBudget };
  },
});

async function buildTodosPromptAugmentation(
  ctx: ActionCtx,
  threadId: string,
  promptMessage: string,
): Promise<string | undefined> {
  const threadMetadata = await ctx.runQuery(
    internal.threads.internal_queries.getThreadMetadata,
    { threadId },
  );
  const organizationId = threadMetadata?.organizationId;
  if (!organizationId) return undefined;

  const todosRecord = await ctx.runQuery(
    internal.thread_todos.internal_queries.getByThread,
    { organizationId, threadId },
  );
  if (!todosRecord || todosRecord.todos.length === 0) return undefined;

  const formatted = formatTodosForReminder(todosRecord.todos);
  const activeLine = todosRecord.activeTodoId
    ? `Active todo: ${todosRecord.activeTodoId}.`
    : 'No todo is currently in_progress. Pick one or create new todos before acting.';
  const reminder =
    `<system-reminder>\n` +
    `Research plan state (persisted, user-visible):\n` +
    `${formatted}\n` +
    `${activeLine}\n` +
    `Integration calls so far: ${todosRecord.integrationCallCount}.\n` +
    `Continue from the in_progress todo before starting new work. If none is active, mark the next pending one in_progress before searching.\n` +
    `</system-reminder>`;

  return promptMessage ? `${promptMessage}\n\n${reminder}` : reminder;
}

function formatTodosReminderMarker(
  status: 'pending' | 'in_progress' | 'done' | 'failed' | 'cancelled',
): string {
  switch (status) {
    case 'done':
      return '[x]';
    case 'in_progress':
      return '[~]';
    case 'failed':
      return '[!]';
    case 'cancelled':
      return '[-]';
    case 'pending':
    default:
      return '[ ]';
  }
}

interface TodoSourceLike {
  url: string;
  title?: string;
  score?: number;
  publishedDate?: string;
  capturedAt: number;
}

function collectUniqueSources(
  todos: Array<{ sources?: TodoSourceLike[] }>,
): Array<{ url: string; title?: string }> {
  const seen = new Set<string>();
  const out: Array<{ url: string; title?: string }> = [];
  for (const todo of todos) {
    for (const src of todo.sources ?? []) {
      if (!src.url || seen.has(src.url)) continue;
      seen.add(src.url);
      out.push({ url: src.url, title: src.title });
    }
  }
  return out;
}

/**
 * Percent-decode URL for display. Many sources are Baidu/CJK URLs with heavy
 * hex-encoded paths — raw they read as noise, decoded they render natural
 * Chinese text. The href stays the original encoded URL so the link resolves.
 */
function prettifyUrl(url: string): string {
  try {
    return decodeURI(url);
  } catch {
    return url;
  }
}

function formatTodosForReminder(
  todos: Array<{
    id: string;
    content: string;
    status: 'pending' | 'in_progress' | 'done' | 'failed' | 'cancelled';
    findingsSummary?: string;
    failureReason?: string;
  }>,
): string {
  return todos
    .map((todo) => {
      const marker = formatTodosReminderMarker(todo.status);
      const findings = todo.findingsSummary ? ` — ${todo.findingsSummary}` : '';
      const failure =
        todo.status === 'failed' && todo.failureReason
          ? ` (failed: ${todo.failureReason})`
          : '';
      return `${marker} [${todo.id}] ${todo.content}${findings}${failure}`;
    })
    .join('\n');
}

/**
 * Guaranteed-synthesis fallback. Runs after the agent finishes generation.
 * If the agent maintained todos but the final message is missing a
 * [[CONCLUSION]] marker, append a deterministic summary so the user always
 * gets a cited report from completed todos — never silence.
 */
export const afterGenerateHook = internalAction({
  args: {
    threadId: v.string(),
    result: v.object({
      text: v.optional(v.string()),
      usage: v.optional(v.any()),
      durationMs: v.optional(v.number()),
    }),
  },
  returns: v.null(),
  handler: async (ctx, args) => {
    const { threadId, result } = args;
    const finalText = typeof result.text === 'string' ? result.text : '';
    if (finalText.includes('[[CONCLUSION]]')) {
      return null;
    }

    const waitingForHuman = await ctx.runQuery(
      internal.approvals.internal_queries.hasPendingHumanInputForThread,
      { threadId },
    );
    if (waitingForHuman) {
      return null;
    }

    const threadMetadata = await ctx.runQuery(
      internal.threads.internal_queries.getThreadMetadata,
      { threadId },
    );
    const organizationId = threadMetadata?.organizationId;
    if (!organizationId) return null;

    const todosRecord = await ctx.runQuery(
      internal.thread_todos.internal_queries.getByThread,
      { organizationId, threadId },
    );
    if (!todosRecord || todosRecord.todos.length === 0) return null;

    const done = todosRecord.todos.filter((t) => t.status === 'done');
    const failed = todosRecord.todos.filter((t) => t.status === 'failed');
    const inProgress = todosRecord.todos.filter(
      (t) => t.status === 'in_progress',
    );
    const pending = todosRecord.todos.filter((t) => t.status === 'pending');

    const lines: string[] = [];
    lines.push('[[CONCLUSION]]');
    if (done.length === 0) {
      lines.push(
        'The research run ended before I could reach a conclusion. The plan below shows where things stood — send a follow-up to continue or narrow the scope.',
      );
    } else {
      lines.push(
        `Summary of findings from ${done.length}/${todosRecord.todos.length} completed todos. The research run ended before a full synthesis could be written — see the key points and details below for what was gathered.`,
      );
    }
    if (done.length > 0) {
      lines.push('', '[[KEY_POINTS]]');
      for (const todo of done) {
        const findings =
          todo.findingsSummary && todo.findingsSummary.trim().length > 0
            ? todo.findingsSummary.trim()
            : todo.content;
        lines.push(`- ${findings}`);
      }
    }
    if (failed.length > 0 || inProgress.length > 0 || pending.length > 0) {
      lines.push('', '[[DETAILS]]');
      if (failed.length > 0) {
        lines.push('Failed todos:');
        for (const todo of failed) {
          const reason = todo.failureReason ?? 'unknown reason';
          lines.push(`- ${todo.content} (${reason})`);
        }
      }
      if (inProgress.length > 0) {
        lines.push('In progress when the run ended:');
        for (const todo of inProgress) {
          lines.push(`- ${todo.content}`);
        }
      }
      if (pending.length > 0) {
        lines.push('Not yet started:');
        for (const todo of pending) {
          lines.push(`- ${todo.content}`);
        }
      }
    }

    const aggregatedSources = collectUniqueSources(todosRecord.todos);
    if (aggregatedSources.length > 0) {
      lines.push('', 'Sources:');
      for (const src of aggregatedSources) {
        const label =
          src.title && src.title.length > 0 ? src.title : prettifyUrl(src.url);
        lines.push(`- [${label}](${src.url})`);
      }
    }

    lines.push(
      '',
      '_This summary was auto-generated from the research plan state. Ask a follow-up to investigate further._',
    );

    await saveMessage(ctx, components.agent, {
      threadId,
      message: {
        role: 'assistant',
        content: lines.join('\n'),
      },
    });
    return null;
  },
});
