/**
 * Chat Mappings - Topic and Message transformation functions for Dexie → SQLite migration
 *
 * This file contains pure transformation functions that convert old data structures
 * to new SQLite-compatible formats. All functions are stateless and side-effect free.
 *
 * ## Data Flow Overview:
 *
 * ### Topics:
 * - Source: Redux `assistants.topics[]` + Dexie `topics` table (for messages)
 * - Target: SQLite `topicTable`
 *
 * ### Messages:
 * - Source: Dexie `topics.messages[]` (embedded in topic) + `message_blocks` table
 * - Target: SQLite `messageTable` with AI SDK parts in `data.parts`
 *
 * ## Key Transformations:
 *
 * 1. **Message Order → Tree Structure**
 *    - Old: Linear array `topic.messages[]` with array index as order
 *    - New: Tree via `parentId` + `siblingsGroupId`
 *
 * 2. **Multi-model Responses**
 *    - Old: Multiple messages share same `askId`, `foldSelected` marks active
 *    - New: Same `parentId` + non-zero `siblingsGroupId` groups siblings
 *
 * 3. **Block → Parts**
 *    - Old: `message.blocks: string[]` (IDs) + separate `message_blocks` table
 *    - New: `message.data.parts` (AI SDK UIMessage parts, inline JSON)
 *
 * 4. **Citations → References**
 *    - Old: Separate `CitationMessageBlock` with response/knowledge/memories
 *    - New: Merged into `MainTextBlock.references` as typed ContentReference[]
 *
 * 5. **Mentions Dropped**
 *    - Old: `message.mentions: Model[]`
 *    - New: Not migrated — derivable from sibling responses' modelId + siblingsGroupId
 *
 * @since v2.0.0
 */

import fs from 'node:fs/promises'
import path from 'node:path'

import { fileEntryTable } from '@data/db/schemas/file'
import type { DbType } from '@data/db/types'
import { loggerService } from '@logger'
import type { FileMetadata } from '@shared/data/types/file/legacyFileMetadata'
import type {
  CherryMessagePart,
  CitationReference,
  CitationType,
  ContentReference,
  DataUIPart,
  DynamicToolUIPart,
  FileUIPart,
  MessageData,
  MessageStats,
  ModelSnapshot,
  ReasoningUIPart,
  ReferenceCategory,
  SerializedErrorData,
  TextUIPart
} from '@shared/data/types/message'
import type { CherryDataPartTypes, CherryToolMeta } from '@shared/data/types/uiParts'
import { withCherryMeta } from '@shared/data/types/uiParts'
import type { Base64String, FilePath } from '@shared/types/file/common'
import type { SourceUrlUIPart } from 'ai'
import mime from 'mime'
import { v7 as uuidv7 } from 'uuid'

import { legacyModelToUniqueId } from '../transformers/ModelTransformers'

const logger = loggerService.withContext('ChatMappings')

/**
 * Optional dependencies threaded through the mapper. Currently only used by
 * the image case to promote v1 inline base64 (either `block.url = 'data:...'`
 * or `block.metadata.generateImageResponse.images[]` from upgraded legacy
 * data) into v2 `file_entry` rows so the bytes leave the message JSON blob
 * and gain `fileEntryId` / path-resilience.
 *
 * Migration runs in `preboot/` before any `WhenReady` lifecycle service is
 * up, so this can't go through `application.get('FileManager')`. We follow
 * the established migration pattern (see `FileMigrator.execute` at
 * `src/main/data/migration/v2/migrators/FileMigrator.ts:254`) and write
 * directly to `fileEntryTable` via the migration's own DB handle. The same
 * pattern of "decode → write physical → insert row" that
 * `internal/entry/create.ts:createInternal` runs at service-time is open-
 * coded inline here so the mapper can stay service-free.
 *
 * When omitted, inline base64 is left in place: `block.url = data:` becomes
 * `FileUIPart.url = data:` (functional via fileProcessor's pass-through);
 * `metadata.generateImageResponse.images` is dropped (current behavior).
 */
export interface ChatMappingDeps {
  db: DbType
  filesDataDir: string
}

// ============================================================================
// Old Type Definitions (Source Data Structures)
// ============================================================================

/**
 * Old Topic type from Redux assistants slice
 * Source: src/renderer/types/index.ts
 */
export interface OldTopic {
  id: string
  type?: 'chat' | 'session' // Dropped in new schema
  assistantId: string
  name: string
  createdAt: string
  updatedAt: string
  messages: OldMessage[]
  pinned?: boolean
  prompt?: string
  isNameManuallyEdited?: boolean
}

/**
 * Old Assistant type from Redux state
 * Note: In Redux state, assistant.topics[] contains topic metadata (but with messages: [])
 */
export interface OldAssistant {
  id: string
  name: string
  emoji?: string
  type: string
  topics?: OldTopicMeta[] // Topics are nested inside assistants in Redux
}

/**
 * Old Topic metadata from Redux assistants.topics[]
 *
 * Redux stores topic metadata (name, pinned, etc.) but clears messages[] to reduce storage.
 * Dexie stores topics with messages[] but may have stale metadata.
 * Migration merges: Redux metadata + Dexie messages.
 */
export interface OldTopicMeta {
  id: string
  name: string
  pinned?: boolean
  prompt?: string
  isNameManuallyEdited?: boolean
  createdAt?: string
  updatedAt?: string
}

/**
 * Old Model type for extracting ModelMeta
 */
export interface OldModel {
  id: string
  name: string
  provider: string
  group: string
}

/**
 * Old Message type from Dexie topics table
 * Source: src/renderer/types/newMessage.ts
 */
export interface OldMessage {
  id: string
  role: 'user' | 'assistant' | 'system'
  assistantId: string
  topicId: string
  createdAt: string
  updatedAt?: string
  // Old status includes more values, we normalize to success/error/paused
  status: 'sending' | 'pending' | 'searching' | 'processing' | 'success' | 'paused' | 'error'

  // Model info
  modelId?: string
  model?: OldModel

  // Multi-model response fields
  askId?: string // Links to user message ID
  foldSelected?: boolean // True if this is the selected response in fold view
  multiModelMessageStyle?: string // UI state, dropped

  // Content
  blocks: string[] // Block IDs referencing message_blocks table

  // Metadata
  usage?: OldUsage
  metrics?: OldMetrics

  // Dropped: mentions are redundant in tree-based architecture
  // (derivable from sibling response messages' modelId + siblingsGroupId)
  mentions?: OldModel[]

  // Dropped fields
  type?: 'clear' | 'text' | '@'
  useful?: boolean
  enabledMCPs?: unknown[]
  agentSessionId?: string
  providerMetadata?: unknown
  // Legacy span pointer; dropped because v1 span detail files are not migrated.
  traceId?: string
}

/**
 * Old Usage type for token consumption
 */
export interface OldUsage {
  prompt_tokens?: number
  completion_tokens?: number
  total_tokens?: number
  thoughts_tokens?: number
  cost?: number
}

/**
 * Old Metrics type for performance measurement
 */
export interface OldMetrics {
  completion_tokens?: number
  time_completion_millsec?: number
  time_first_token_millsec?: number
  time_thinking_millsec?: number
}

/**
 * Old MessageBlock base type
 */
export interface OldMessageBlock {
  id: string
  messageId: string
  type: string
  createdAt: string
  updatedAt?: string
  status: string // Dropped in new schema
  model?: OldModel // Dropped in new schema
  metadata?: Record<string, unknown>
  error?: SerializedErrorData
}

/**
 * Old MainTextMessageBlock
 */
export interface OldMainTextBlock extends OldMessageBlock {
  type: 'main_text'
  content: string
  knowledgeBaseIds?: string[] // Dropped (deprecated)
  citationReferences?: Array<{
    citationBlockId?: string
    citationBlockSource?: string
  }> // Dropped (replaced by references)
}

/**
 * Old ThinkingMessageBlock
 */
export interface OldThinkingBlock extends OldMessageBlock {
  type: 'thinking'
  content: string
  thinking_millsec: number // → thinkingMs
}

/**
 * Old TranslationMessageBlock
 */
export interface OldTranslationBlock extends OldMessageBlock {
  type: 'translation'
  content: string
  sourceBlockId?: string
  sourceLanguage?: string
  targetLanguage: string
}

/**
 * Old CodeMessageBlock
 */
export interface OldCodeBlock extends OldMessageBlock {
  type: 'code'
  content: string
  language: string
}

/**
 * Old ImageMessageBlock
 */
export interface OldImageBlock extends OldMessageBlock {
  type: 'image'
  url?: string
  file?: FileMetadata
}

/**
 * Old FileMessageBlock
 */
export interface OldFileBlock extends OldMessageBlock {
  type: 'file'
  file: FileMetadata
}

/**
 * Old VideoMessageBlock
 */
export interface OldVideoBlock extends OldMessageBlock {
  type: 'video'
  url?: string
  filePath?: string
}

/**
 * Old ToolMessageBlock
 */
export interface OldToolBlock extends OldMessageBlock {
  type: 'tool'
  toolId: string
  toolName?: string
  arguments?: Record<string, unknown>
  /** MCP CallToolResult format: { content: [{type, text}], isError: boolean } */
  content?:
    | {
        content?: Array<{ type: string; text?: string; data?: string; mimeType?: string }>
        isError?: boolean
      }
    | string
  metadata?: Record<string, unknown> & {
    /** Full McpToolResponse preserved at save time */
    rawMcpToolResponse?: {
      id: string
      tool: { id: string; name: string; type: string; serverId?: string; serverName?: string; description?: string }
      arguments?: Record<string, unknown>
      status: string
      response?: unknown
      toolCallId: string
    }
  }
}

/**
 * Old CitationMessageBlock - contains web search, knowledge, and memory references
 * This is the primary source for ContentReference transformation
 */
export interface OldCitationBlock extends OldMessageBlock {
  type: 'citation'
  response?: {
    results?: unknown
    source: unknown
  }
  knowledge?: Array<{
    id: number
    content: string
    sourceUrl: string
    type: string
    file?: unknown
    metadata?: Record<string, unknown>
  }>
  memories?: Array<{
    id: string
    memory: string
    hash?: string
    createdAt?: string
    updatedAt?: string
    score?: number
    metadata?: Record<string, unknown>
  }>
}

/**
 * Old ErrorMessageBlock
 */
export interface OldErrorBlock extends OldMessageBlock {
  type: 'error'
}

/**
 * Old CompactMessageBlock
 */
export interface OldCompactBlock extends OldMessageBlock {
  type: 'compact'
  content: string
  compactedContent: string
}

/**
 * Union of all old block types
 */
export type OldBlock =
  | OldMainTextBlock
  | OldThinkingBlock
  | OldTranslationBlock
  | OldCodeBlock
  | OldImageBlock
  | OldFileBlock
  | OldVideoBlock
  | OldToolBlock
  | OldCitationBlock
  | OldErrorBlock
  | OldCompactBlock
  | OldMessageBlock

// ============================================================================
// New Type Definitions (Target Data Structures)
// ============================================================================

/**
 * New Topic for SQLite insertion
 * Matches topicTable schema
 */
export interface NewTopic {
  id: string
  name: string
  isNameManuallyEdited: boolean
  assistantId: string | null
  activeNodeId: string | null
  groupId: string | null
  orderKey: string
  createdAt: number // timestamp
  updatedAt: number // timestamp
}

/**
 * New Message for SQLite insertion
 * Matches messageTable schema
 */
export interface NewMessage {
  id: string
  parentId: string | null
  topicId: string
  role: string
  data: MessageData
  searchableText: string
  status: 'success' | 'error' | 'paused'
  siblingsGroupId: number
  modelId: string | null
  modelSnapshot: ModelSnapshot | null
  stats: MessageStats | null
  createdAt: number // timestamp
  updatedAt: number // timestamp
}

// ============================================================================
// Topic Transformation Functions
// ============================================================================

/**
 * Transform old Topic to new Topic format
 *
 * @param oldTopic - Source topic from Redux/Dexie
 * @param activeNodeId - Last message ID to set as active node
 * @returns New topic ready for SQLite insertion
 *
 * ## Field Mapping:
 * | Source | Target | Notes |
 * |--------|--------|-------|
 * | id | id | Direct copy |
 * | name | name | Direct copy |
 * | isNameManuallyEdited | isNameManuallyEdited | Direct copy |
 * | assistantId | assistantId | FK to assistant table (validated) |
 * | (computed) | activeNodeId | Last message ID |
 * | (none) | groupId | null (new field) |
 * | (none) | orderKey | placeholder; stamped post-stream by the migrator |
 * | createdAt | createdAt | ISO string → timestamp |
 * | updatedAt | updatedAt | ISO string → timestamp |
 *
 * ## Dropped Fields:
 * - type ('chat' | 'session'): No longer needed in new schema
 * - prompt: Topic-level prompt removed from schema; assistant prompt is authoritative
 * - pinned: Pin state lives on the polymorphic `pin` table now; the migrator
 *   reads `oldTopic.pinned` separately and emits a `pin` row for it.
 */
export function transformTopic(oldTopic: OldTopic, activeNodeId: string | null): NewTopic {
  return {
    id: oldTopic.id,
    name: oldTopic.name || '',
    isNameManuallyEdited: oldTopic.isNameManuallyEdited ?? false,
    assistantId: oldTopic.assistantId || null,
    activeNodeId,
    groupId: null, // New field, no migration source
    orderKey: '', // Stamped by ChatMigrator.insertStagedTopics post-stream.
    createdAt: parseTimestamp(oldTopic.createdAt),
    updatedAt: parseTimestamp(oldTopic.updatedAt)
  }
}

// ============================================================================
// Message Transformation Functions
// ============================================================================

/**
 * Transform old Message to new Message format
 *
 * This is the core message transformation function. It handles:
 * - Status normalization
 * - Block transformation (IDs → inline data)
 * - Citation merging into references
 * - Stats merging (usage + metrics)
 *
 * @param oldMessage - Source message from Dexie
 * @param parentId - Computed parent message ID (from tree building)
 * @param siblingsGroupId - Computed siblings group ID (from multi-model detection)
 * @param blocks - Resolved block data from message_blocks table

 * @param correctTopicId - The correct topic ID (from parent topic, not from message)
 * @returns New message ready for SQLite insertion
 *
 * ## Field Mapping:
 * | Source | Target | Notes |
 * |--------|--------|-------|
 * | id | id | Direct copy |
 * | (computed) | parentId | From tree building algorithm |
 * | (parameter) | topicId | From correctTopicId param (ensures consistency) |
 * | role | role | Direct copy |
 * | blocks + mentions + citations | data | Complex transformation |
 * | (extracted) | searchableText | Extracted from text blocks |
 * | status | status | Normalized to success/error/paused |
 * | (computed) | siblingsGroupId | From multi-model detection |
 * | model/modelId | modelId | Composite (provider::modelId) or raw fallback |
 * | traceId | - | Dropped: legacy span detail files are not migrated |
 * | usage + metrics | stats | Merged into single stats object |
 * | createdAt | createdAt | ISO string → timestamp |
 * | updatedAt | updatedAt | ISO string → timestamp |
 *
 * ## Dropped Fields:
 * - type ('clear' | 'text' | '@')
 * - useful (boolean)
 * - enabledMCPs (deprecated)
 * - agentSessionId (session identifier)
 * - traceId (span detail files are outside the v1 chat migration source set)
 * - providerMetadata (raw provider data)
 * - multiModelMessageStyle (UI state)
 * - askId (replaced by parentId)
 * - foldSelected (replaced by siblingsGroupId)
 */
export async function transformMessage(
  oldMessage: OldMessage,
  parentId: string | null,
  siblingsGroupId: number,
  blocks: OldBlock[],
  correctTopicId: string,
  deps?: ChatMappingDeps
): Promise<NewMessage> {
  // Transform blocks to AI SDK UIMessage.parts format
  const { parts, citationReferences, searchableText } = await transformBlocksToParts(blocks, deps)

  // Mentions are NOT migrated. In the new tree-based architecture, which models
  // responded to a user message can be derived from sibling response messages'
  // modelId + siblingsGroupId, making stored mentions redundant.

  // Merge citations into the first TextUIPart's providerMetadata.cherry.references
  if (citationReferences.length > 0) {
    const textPartIndex = parts.findIndex((p): p is TextUIPart => p.type === 'text')
    if (textPartIndex >= 0) {
      parts[textPartIndex] = withCherryMeta(parts[textPartIndex] as TextUIPart, {
        references: citationReferences
      })
    }
  }

  return {
    id: oldMessage.id,
    parentId,
    topicId: correctTopicId,
    role: oldMessage.role,
    data: { parts },
    searchableText: searchableText || '',
    status: normalizeStatus(oldMessage.status),
    siblingsGroupId,
    modelId: legacyModelToUniqueId(oldMessage.model, oldMessage.modelId),
    // Snapshot of model at message creation time for historical display
    modelSnapshot: buildModelSnapshot(oldMessage.model),
    stats: mergeStats(oldMessage.usage, oldMessage.metrics),
    createdAt: parseTimestamp(oldMessage.createdAt),
    updatedAt: parseTimestamp(oldMessage.updatedAt || oldMessage.createdAt)
  }
}

/**
 * Build a ModelSnapshot from a legacy model object.
 * Returns null if model is missing required fields (id + provider).
 */
function buildModelSnapshot(model: OldMessage['model']): ModelSnapshot | null {
  if (!model || typeof model.id !== 'string' || typeof model.provider !== 'string') return null
  if (!model.id.trim() || !model.provider.trim()) return null
  return {
    id: model.id,
    name: (typeof model.name === 'string' ? model.name : model.id) || model.id,
    provider: model.provider,
    group: typeof model.group === 'string' ? model.group : undefined
  }
}

/**
 * Normalize old status values to new enum
 *
 * Old system has multiple transient states that don't apply to stored messages.
 * Transient states (sending/pending/searching/processing) indicate interrupted
 * operations and are mapped to 'error' — the message was not completed.
 *
 * @param oldStatus - Status from old message
 * @returns Normalized status for new message
 *
 * ## Mapping:
 * - 'success' → 'success'
 * - 'error' → 'error'
 * - 'paused' → 'paused'
 * - 'sending', 'pending', 'searching', 'processing' → 'error' (interrupted)
 */
export function normalizeStatus(oldStatus: OldMessage['status']): 'success' | 'error' | 'paused' {
  switch (oldStatus) {
    case 'success':
      return 'success'
    case 'paused':
      return 'paused'
    case 'error':
    case 'sending':
    case 'pending':
    case 'searching':
    case 'processing':
    default:
      // Transient states in persisted data indicate interrupted operations
      return 'error'
  }
}

/**
 * Merge old usage and metrics into new MessageStats
 *
 * The old system stored token usage and performance metrics in separate objects.
 * The new schema combines them into a single stats object.
 *
 * @param usage - Token usage data from old message
 * @param metrics - Performance metrics from old message
 * @returns Combined MessageStats or null if no data
 *
 * ## Field Mapping:
 * | Source | Target |
 * |--------|--------|
 * | usage.prompt_tokens | promptTokens |
 * | usage.completion_tokens | completionTokens |
 * | usage.total_tokens | totalTokens |
 * | usage.thoughts_tokens | thoughtsTokens |
 * | usage.cost | cost |
 * | metrics.time_first_token_millsec | timeFirstTokenMs |
 * | metrics.time_completion_millsec | timeCompletionMs |
 * | metrics.time_thinking_millsec | timeThinkingMs |
 */
export function mergeStats(usage?: OldUsage, metrics?: OldMetrics): MessageStats | null {
  if (!usage && !metrics) return null

  const stats: MessageStats = {}

  // Token usage
  if (usage) {
    if (usage.prompt_tokens !== undefined) stats.promptTokens = usage.prompt_tokens
    if (usage.completion_tokens !== undefined) stats.completionTokens = usage.completion_tokens
    if (usage.total_tokens !== undefined) stats.totalTokens = usage.total_tokens
    if (usage.thoughts_tokens !== undefined) stats.thoughtsTokens = usage.thoughts_tokens
    if (usage.cost !== undefined) stats.cost = usage.cost
  }

  // Performance metrics
  if (metrics) {
    if (metrics.time_first_token_millsec !== undefined) stats.timeFirstTokenMs = metrics.time_first_token_millsec
    if (metrics.time_completion_millsec !== undefined) stats.timeCompletionMs = metrics.time_completion_millsec
    if (metrics.time_thinking_millsec !== undefined) stats.timeThinkingMs = metrics.time_thinking_millsec
  }

  // Return null if no data was actually added
  return Object.keys(stats).length > 0 ? stats : null
}

// ============================================================================
// Block → UIMessage.parts Transformation (v2 target format)
// ============================================================================

/**
 * Transform old blocks to AI SDK UIMessage.parts format.
 *
 * ## Block → Part Mapping:
 * | Old Block      | New Part                | Notes                                    |
 * |----------------|-------------------------|------------------------------------------|
 * | main_text      | TextUIPart         | content → text, references in metadata   |
 * | thinking       | ReasoningUIPart    | thinkingMs in providerMetadata.cherry     |
 * | tool           | DynamicToolUIPart         | dynamic-tool with output-available state  |
 * | image          | FileUIPart         | fileId resolved to file:// URL            |
 * | file           | FileUIPart         | fileId resolved to file:// URL            |
 * | error          | DataUIPart<CherryDataPartTypes>         | data-error with name/message              |
 * | translation    | DataUIPart<CherryDataPartTypes>         | data-translation                          |
 * | video          | DataUIPart<CherryDataPartTypes>         | data-video                                |
 * | compact        | DataUIPart<CherryDataPartTypes>         | data-compact                              |
 * | code           | DataUIPart<CherryDataPartTypes>         | data-code                                 |
 * | citation (web) | SourceUrlUIPart         | Each web result → source-url part          |
 * | citation (kb)  | (merged into TextPart)  | Knowledge/memory → providerMetadata refs  |
 * | unknown        | (skipped)               | Placeholder blocks are dropped            |
 */
export async function transformBlocksToParts(
  oldBlocks: OldBlock[],
  deps?: ChatMappingDeps
): Promise<{
  parts: CherryMessagePart[]
  citationReferences: ContentReference[]
  searchableText: string
}> {
  const parts: CherryMessagePart[] = []
  const citationReferences: ContentReference[] = []
  const searchableTexts: string[] = []

  for (const oldBlock of oldBlocks) {
    const result = await transformSingleBlockToPart(oldBlock, deps)

    if (result.part) {
      parts.push(result.part)
    }

    if (result.extraParts) {
      parts.push(...result.extraParts)
    }

    if (result.citations) {
      citationReferences.push(...result.citations)
    }

    if (result.searchableText) {
      searchableTexts.push(result.searchableText)
    }
  }

  return {
    parts,
    citationReferences,
    searchableText: searchableTexts.join('\n')
  }
}

/**
 * Transform a single old block to UIMessage part(s).
 * Most blocks produce a single part, but citation blocks may produce multiple
 * (SourceUrlUIPart for web results + DataUIPart for knowledge/memory).
 */
async function transformSingleBlockToPart(
  oldBlock: OldBlock,
  deps?: ChatMappingDeps
): Promise<{
  part: CherryMessagePart | null
  extraParts: CherryMessagePart[] | null
  citations: ContentReference[] | null
  searchableText: string | null
}> {
  switch (oldBlock.type) {
    case 'main_text': {
      const block = oldBlock as OldMainTextBlock
      const part: TextUIPart = {
        type: 'text',
        text: block.content,
        state: 'done'
      }
      return { part, extraParts: null, citations: null, searchableText: block.content }
    }

    case 'thinking': {
      const block = oldBlock as OldThinkingBlock
      const basePart: ReasoningUIPart = {
        type: 'reasoning',
        text: block.content,
        state: 'done'
      }
      const part = withCherryMeta(basePart, { thinkingMs: block.thinking_millsec })
      return { part, extraParts: null, citations: null, searchableText: block.content }
    }

    case 'tool': {
      const block = oldBlock as OldToolBlock
      const raw = block.metadata?.rawMcpToolResponse
      const contentObj = typeof block.content === 'object' ? block.content : null

      const rawName = block.toolName || raw?.tool?.name || 'unknown'
      const serverName = raw?.tool?.serverName
      const toolName = serverName ? `${serverName}: ${rawName}` : rawName
      const toolCallId = block.toolId || raw?.toolCallId || raw?.id || block.id
      const input = block.arguments ?? raw?.arguments ?? {}
      const output = raw?.response ?? block.content
      const isError = contentObj?.isError === true || raw?.status === 'error'
      const rawToolType = raw?.tool?.type
      const toolType =
        rawToolType === 'mcp' || rawToolType === 'builtin' || rawToolType === 'provider' ? rawToolType : undefined
      const toolMetadata: CherryToolMeta['tool'] | undefined = raw?.tool
        ? {
            ...(toolType ? { type: toolType } : {}),
            ...(raw.tool.serverId ? { serverId: raw.tool.serverId } : {}),
            ...(raw.tool.serverName ? { serverName: raw.tool.serverName } : {})
          }
        : undefined
      const callProviderMetadata = raw?.tool
        ? {
            cherry: {
              tool: {
                ...(toolType ? { type: toolType } : {}),
                ...(raw.tool.name ? { name: raw.tool.name } : {}),
                ...(raw.tool.description ? { description: raw.tool.description } : {}),
                ...(raw.tool.serverId ? { serverId: raw.tool.serverId } : {}),
                ...(raw.tool.serverName ? { serverName: raw.tool.serverName } : {})
              }
            }
          }
        : undefined

      const base = {
        type: 'dynamic-tool' as const,
        toolName,
        toolCallId,
        input,
        ...(callProviderMetadata ? { callProviderMetadata } : {})
      }

      const partWithoutMeta: DynamicToolUIPart = isError
        ? { ...base, state: 'output-error', errorText: typeof output === 'string' ? output : JSON.stringify(output) }
        : { ...base, state: 'output-available', output }
      const part =
        toolMetadata && Object.keys(toolMetadata).length > 0
          ? withCherryMeta(partWithoutMeta, { tool: toolMetadata })
          : partWithoutMeta

      return { part, extraParts: null, citations: null, searchableText: null }
    }

    case 'image': {
      const block = oldBlock as OldImageBlock
      const fileParts = await collectImageFileParts(block, deps)
      const part = fileParts[0] ?? null
      const extraParts = fileParts.length > 1 ? fileParts.slice(1) : null
      return { part, extraParts, citations: null, searchableText: null }
    }

    case 'file': {
      const block = oldBlock as OldFileBlock
      const basePart: FileUIPart = {
        type: 'file',
        mediaType: inferMediaType(block.file.ext, 'application/octet-stream'),
        url: block.file.path ? `file://${block.file.path}` : '',
        ...(block.file.origin_name ? { filename: block.file.origin_name } : {})
      }
      const part = block.file.id ? withCherryMeta(basePart, { fileEntryId: block.file.id }) : basePart
      return { part, extraParts: null, citations: null, searchableText: null }
    }

    case 'error': {
      const part: DataUIPart<CherryDataPartTypes> = {
        type: 'data-error',
        data: {
          name: oldBlock.error?.name ?? null,
          message: oldBlock.error?.message ?? null
        }
      }
      return { part, extraParts: null, citations: null, searchableText: null }
    }

    case 'translation': {
      const block = oldBlock as OldTranslationBlock
      const part: DataUIPart<CherryDataPartTypes> = {
        type: 'data-translation',
        data: {
          content: block.content,
          targetLanguage: block.targetLanguage,
          ...(block.sourceLanguage ? { sourceLanguage: block.sourceLanguage } : {}),
          ...(block.sourceBlockId ? { sourceBlockId: block.sourceBlockId } : {})
        }
      }
      return { part, extraParts: null, citations: null, searchableText: block.content }
    }

    case 'video': {
      const block = oldBlock as OldVideoBlock
      const part: DataUIPart<CherryDataPartTypes> = {
        type: 'data-video',
        data: {
          ...(block.url ? { url: block.url } : {}),
          ...(block.filePath ? { filePath: block.filePath } : {})
        }
      }
      return { part, extraParts: null, citations: null, searchableText: null }
    }

    case 'compact': {
      const block = oldBlock as OldCompactBlock
      const part: DataUIPart<CherryDataPartTypes> = {
        type: 'data-compact',
        data: {
          content: block.content,
          compactedContent: block.compactedContent
        }
      }
      return { part, extraParts: null, citations: null, searchableText: block.content }
    }

    case 'code': {
      const block = oldBlock as OldCodeBlock
      const part: DataUIPart<CherryDataPartTypes> = {
        type: 'data-code',
        data: {
          content: block.content,
          language: block.language
        }
      }
      return { part, extraParts: null, citations: null, searchableText: block.content }
    }

    case 'citation': {
      const block = oldBlock as OldCitationBlock
      const citations = extractCitationReferences(block)
      const sourceParts = extractSourceUrlParts(block)

      return { part: null, extraParts: sourceParts.length > 0 ? sourceParts : null, citations, searchableText: null }
    }

    case 'unknown':
    default:
      return { part: null, extraParts: null, citations: null, searchableText: null }
  }
}

const BASE64_DATA_URL_RE = /^data:([^;,]+);base64,(.+)$/
const MIGRATED_IMAGE_NAME = 'migrated-image'

/**
 * Verify a string actually conforms to the `Base64String` template literal:
 * `data:<mime>;base64,<payload>`. This excludes `data:text/plain,Hello`
 * (non-base64 data URL) and `data:` with no payload.
 */
function isBase64DataUrl(s: string): s is Base64String {
  return BASE64_DATA_URL_RE.test(s)
}

/** Wrap a raw base64 payload into a canonical data URL, or pass through if already one. */
function toBase64DataUrl(raw: string, mimeFallback: string): Base64String {
  if (isBase64DataUrl(raw)) return raw
  return `data:${mimeFallback};base64,${raw}`
}

/** Best-effort MIME inference from a data URL prefix; falls back to image/png. */
function mediaTypeFromDataUrl(dataUrl: Base64String): string {
  const match = BASE64_DATA_URL_RE.exec(dataUrl)
  return match?.[1] ?? 'image/png'
}

async function promoteBase64ToFileEntry(
  db: DbType,
  filesDataDir: string,
  dataUrl: Base64String,
  blockId: string
): Promise<FileUIPart | null> {
  const match = BASE64_DATA_URL_RE.exec(dataUrl)
  if (!match) return null
  const mimeType = match[1]
  const payload = match[2]
  const ext = mime.getExtension(mimeType)
  const id = uuidv7()
  const filename = ext ? `${id}.${ext}` : id
  const physicalPath = path.join(filesDataDir, filename) as FilePath
  const bytes = Buffer.from(payload, 'base64')
  let physicalWritten = false

  try {
    // Write physical file first. If we crash before the DB insert lands,
    // the orphan checker won't sweep this file (no file_entry row means
    // no DB linkage to look up). Same risk model as the rest of
    // FileMigrator's transform path; acceptable for a migration step.
    await fs.mkdir(path.dirname(physicalPath), { recursive: true })
    await fs.writeFile(physicalPath, bytes)
    physicalWritten = true

    const now = Date.now()
    await db.insert(fileEntryTable).values({
      id,
      origin: 'internal',
      name: MIGRATED_IMAGE_NAME,
      ext: ext ?? null,
      size: bytes.length,
      externalPath: null,
      deletedAt: null,
      createdAt: now,
      updatedAt: now
    })

    const basePart: FileUIPart = {
      type: 'file',
      mediaType: mimeType,
      url: `file://${physicalPath}`,
      filename: ext ? `${MIGRATED_IMAGE_NAME}.${ext}` : MIGRATED_IMAGE_NAME
    }
    return withCherryMeta(basePart, { fileEntryId: id })
  } catch (error) {
    if (physicalWritten) {
      // Best-effort: clean up the file we just wrote so we don't leave
      // bytes on disk that no DB row references.
      await fs.unlink(physicalPath).catch(() => {})
    }
    logger.warn('Failed to promote v1 base64 image to v2 file_entry; dropping image', {
      blockId,
      error: error instanceof Error ? error.message : String(error)
    })
    return null
  }
}

/**
 * Collect FileUIParts for a v1 OldImageBlock. v1 stored image bytes in any of:
 *
 *  1. `block.file` — file already on disk; we already have a v1 FileEntryId.
 *     v2 keeps the id and points `url` at the disk path.
 *  2. `block.url = file://...` or `https://...` — remote / local URL; passes
 *     through as-is (no fileEntryId because we don't own it).
 *  3. `block.url = data:...;base64,...` — inline base64; **promoted** via
 *     `createInternalEntry({source:'base64'})` so the bytes leave the message
 *     JSON and gain a v2 fileEntryId. Falls back to inline data URL if no
 *     FileManager dep is provided.
 *  4. `block.metadata.generateImageResponse.images` with `type === 'base64'`
 *     — array of base64 images from upgraded legacy data (`upgrades.ts:211`
 *     created this shape when migrating older `oldMessage.metadata.generateImage`).
 *     Each image **promoted** to its own FileUIPart.
 *
 * When `deps` is omitted, the base64 cases degrade gracefully (data URL on
 * `block.url` stays inline; `generateImageResponse.images` is dropped, same
 * as before this helper existed) so unrelated tests don't need to mock
 * FileManager.
 */
async function collectImageFileParts(block: OldImageBlock, deps?: ChatMappingDeps): Promise<FileUIPart[]> {
  const parts: FileUIPart[] = []

  // (1) Disk-backed file (canonical for modern v1) — never has inline base64.
  if (block.file) {
    const basePart: FileUIPart = {
      type: 'file',
      mediaType: inferMediaType(block.file.ext, 'image/png'),
      url: block.file.path ? `file://${block.file.path}` : '',
      ...(block.file.origin_name ? { filename: block.file.origin_name } : {})
    }
    parts.push(block.file.id ? withCherryMeta(basePart, { fileEntryId: block.file.id }) : basePart)
  } else if (block.url) {
    // (2)+(3) URL-only image (no disk file).
    if (isBase64DataUrl(block.url)) {
      // `block.url` is now narrowed to `Base64String`; no `as` cast.
      if (deps?.db) {
        const promoted = await promoteBase64ToFileEntry(deps.db, deps.filesDataDir, block.url, block.id)
        if (promoted) parts.push(promoted)
      } else {
        // No promoter → keep inline (fileProcessor passes data: through unchanged).
        parts.push({ type: 'file', mediaType: mediaTypeFromDataUrl(block.url), url: block.url })
      }
    } else {
      // Remote / file:// URL, or a non-base64 data: URL — keep as-is.
      parts.push({ type: 'file', mediaType: 'image/png', url: block.url })
    }
  }

  // (4) Legacy `metadata.generateImageResponse.images` (upgraded older v1).
  const generated = (block.metadata as { generateImageResponse?: { type?: string; images?: unknown } } | undefined)
    ?.generateImageResponse
  const isBase64Mode = generated?.type === 'base64'
  const rawImages = Array.isArray(generated?.images) ? generated.images.filter((x) => typeof x === 'string') : []
  if (isBase64Mode && rawImages.length > 0) {
    if (deps?.db) {
      for (const raw of rawImages) {
        const dataUrl = toBase64DataUrl(raw, 'image/png')
        const promoted = await promoteBase64ToFileEntry(deps.db, deps.filesDataDir, dataUrl, block.id)
        if (promoted) parts.push(promoted)
      }
    } else {
      logger.warn(
        'OldImageBlock carries metadata.generateImageResponse.images but no db dep was provided; dropping legacy base64 images',
        {
          blockId: block.id,
          imageCount: rawImages.length
        }
      )
    }
  }

  return parts
}

/**
 * Extract SourceUrlUIPart[] from web search results in a CitationBlock.
 *
 * Web search results that have a URL are converted to AI SDK's native
 * SourceUrlUIPart, which useChat can render directly.
 */
function extractSourceUrlParts(citationBlock: OldCitationBlock): CherryMessagePart[] {
  const parts: CherryMessagePart[] = []

  if (!citationBlock.response?.results) return parts

  const results = citationBlock.response.results
  if (!Array.isArray(results)) return parts

  for (let i = 0; i < results.length; i++) {
    const result = results[i]
    if (typeof result !== 'object' || result === null) continue
    const entry = result as Record<string, unknown>
    const url = typeof entry.url === 'string' ? entry.url : undefined
    if (!url) continue

    const sourcePart: SourceUrlUIPart = {
      type: 'source-url',
      sourceId: `citation-${i}`,
      url,
      title: typeof entry.title === 'string' ? entry.title : undefined
    }
    parts.push(sourcePart)
  }

  return parts
}

/**
 * Extract ContentReferences from old CitationMessageBlock
 *
 * Old CitationBlocks contain three types of citations:
 * - response (web search results) → WebCitationReference
 * - knowledge (knowledge base refs) → KnowledgeCitationReference
 * - memories (memory items) → MemoryCitationReference
 *
 * @param citationBlock - Old CitationMessageBlock
 * @returns Array of ContentReferences
 */
export function extractCitationReferences(citationBlock: OldCitationBlock): ContentReference[] {
  const references: ContentReference[] = []

  // Web search citations
  if (citationBlock.response) {
    references.push({
      category: 'citation' as ReferenceCategory.CITATION,
      citationType: 'web' as CitationType.WEB,
      content: {
        results: citationBlock.response.results,
        source: citationBlock.response.source
      }
    } as CitationReference)
  }

  // Knowledge base citations
  if (citationBlock.knowledge && citationBlock.knowledge.length > 0) {
    references.push({
      category: 'citation' as ReferenceCategory.CITATION,
      citationType: 'knowledge' as CitationType.KNOWLEDGE,
      content: citationBlock.knowledge.map((k) => ({
        id: k.id,
        content: k.content,
        sourceUrl: k.sourceUrl,
        type: k.type,
        file: k.file,
        metadata: k.metadata
      }))
    } as CitationReference)
  }

  // Memory citations
  if (citationBlock.memories && citationBlock.memories.length > 0) {
    references.push({
      category: 'citation' as ReferenceCategory.CITATION,
      citationType: 'memory' as CitationType.MEMORY,
      content: citationBlock.memories.map((m) => ({
        id: m.id,
        memory: m.memory,
        hash: m.hash,
        createdAt: m.createdAt,
        updatedAt: m.updatedAt,
        score: m.score,
        metadata: m.metadata
      }))
    } as CitationReference)
  }

  return references
}

// ============================================================================
// Tree Building Functions
// ============================================================================

/**
 * Build message tree structure from linear message array
 *
 * The old system stores messages in a linear array. The new system uses
 * a tree structure with parentId for navigation.
 *
 * ## Algorithm:
 * 1. Process messages in array order (which is the conversation order)
 * 2. For each message:
 *    - If it's a user message or first message, parent is the previous message
 *    - If it's an assistant message with askId, link to that user message
 *    - If multiple messages share same askId, they form a siblings group
 *
 * @param messages - Messages in array order from old topic
 * @returns Map of messageId → { parentId, siblingsGroupId }
 *
 * ## Example:
 * ```
 * Input: [u1, a1, u2, a2, a3(askId=u2,foldSelected), a4(askId=u2), u3]
 *
 * Output:
 * u1: { parentId: null, siblingsGroupId: 0 }
 * a1: { parentId: 'u1', siblingsGroupId: 0 }
 * u2: { parentId: 'a1', siblingsGroupId: 0 }
 * a2: { parentId: 'u2', siblingsGroupId: 1 }  // Multi-model group
 * a3: { parentId: 'u2', siblingsGroupId: 1 }  // Selected one
 * a4: { parentId: 'u2', siblingsGroupId: 1 }
 * u3: { parentId: 'a3', siblingsGroupId: 0 }  // Links to foldSelected
 * ```
 */
export function buildMessageTree(
  messages: OldMessage[]
): Map<string, { parentId: string | null; siblingsGroupId: number }> {
  const result = new Map<string, { parentId: string | null; siblingsGroupId: number }>()

  if (messages.length === 0) return result

  // Track askId → siblingsGroupId mapping
  // Each unique askId with multiple responses gets a unique siblingsGroupId
  const askIdToGroupId = new Map<string, number>()
  const askIdCounts = new Map<string, number>()

  // First pass: count messages per askId to identify multi-model responses
  for (const msg of messages) {
    if (msg.askId) {
      askIdCounts.set(msg.askId, (askIdCounts.get(msg.askId) || 0) + 1)
    }
  }

  // Assign group IDs to askIds with multiple responses
  let nextGroupId = 1
  for (const [askId, count] of askIdCounts) {
    if (count > 1) {
      askIdToGroupId.set(askId, nextGroupId++)
    }
  }

  // Build set of known message IDs for validating references
  const knownIds = new Set(messages.map((m) => m.id))

  // Track fallback parent for orphaned askId groups (user message deleted)
  // All messages in the same orphaned group share the previousMessageId at the time
  // the first group member is encountered, preserving sibling relationships.
  const orphanedGroupParent = new Map<string, string | null>()

  // Second pass: build parent/sibling relationships
  let previousMessageId: string | null = null
  let lastNonGroupMessageId: string | null = null // Last message not in a group, for linking subsequent user messages
  let lastGroupFallbackId: string | null = null // Last group member as fallback when no foldSelected
  let groupHasFoldSelected = false // Whether current group has a foldSelected member

  for (let i = 0; i < messages.length; i++) {
    const msg = messages[i]
    let parentId: string | null = null
    let siblingsGroupId = 0

    if (msg.askId && askIdToGroupId.has(msg.askId)) {
      siblingsGroupId = askIdToGroupId.get(msg.askId)!

      if (knownIds.has(msg.askId)) {
        // Normal multi-model: parent is the user message
        parentId = msg.askId
      } else {
        // Orphaned multi-model: user message deleted, share a common fallback parent
        if (!orphanedGroupParent.has(msg.askId)) {
          orphanedGroupParent.set(msg.askId, previousMessageId)
        }
        parentId = orphanedGroupParent.get(msg.askId) ?? null
      }

      // Track selected response or last group member for linking subsequent user messages
      if (msg.foldSelected) {
        lastNonGroupMessageId = msg.id
        groupHasFoldSelected = true
      }
      if (!groupHasFoldSelected) {
        lastGroupFallbackId = msg.id
      }
    } else if (msg.role === 'user' && (lastNonGroupMessageId || lastGroupFallbackId)) {
      // User message after a multi-model group links to the selected (or last) response.
      // lastGroupFallbackId takes priority: it means the group had no foldSelected,
      // so the user message should follow the last group member, not the pre-group message.
      parentId = lastGroupFallbackId ?? lastNonGroupMessageId
      lastNonGroupMessageId = null
      lastGroupFallbackId = null
      groupHasFoldSelected = false
    } else {
      // Normal sequential message - parent is previous message
      parentId = previousMessageId
    }

    result.set(msg.id, { parentId, siblingsGroupId })

    // Update tracking for next iteration
    previousMessageId = msg.id

    // Update lastNonGroupMessageId for non-group messages
    if (siblingsGroupId === 0) {
      lastNonGroupMessageId = msg.id
      lastGroupFallbackId = null
      groupHasFoldSelected = false
    }
  }

  return result
}

/**
 * Find the activeNodeId for a topic
 *
 * The activeNodeId should be the last message in the main conversation thread.
 * For multi-model responses, it should be the foldSelected one.
 *
 * @param messages - Messages in array order
 * @returns The ID of the last message (or foldSelected if applicable)
 */
export function findActiveNodeId(messages: OldMessage[]): string | null {
  if (messages.length === 0) return null

  // Find the last message
  // If it's part of a multi-model group, find the foldSelected one
  const lastMsg = messages[messages.length - 1]

  if (lastMsg.askId) {
    // Check if there's a foldSelected message with the same askId
    const selectedMsg = messages.find((m) => m.askId === lastMsg.askId && m.foldSelected)
    if (selectedMsg) return selectedMsg.id
  }

  return lastMsg.id
}

// ============================================================================
// Utility Functions
// ============================================================================

/**
 * Infer MIME type from file extension.
 */
function inferMediaType(ext: string | undefined, fallback: string): string {
  if (!ext) return fallback
  const normalized = ext.startsWith('.') ? ext.slice(1).toLowerCase() : ext.toLowerCase()
  const mimeMap: Record<string, string> = {
    png: 'image/png',
    jpg: 'image/jpeg',
    jpeg: 'image/jpeg',
    gif: 'image/gif',
    webp: 'image/webp',
    svg: 'image/svg+xml',
    bmp: 'image/bmp',
    ico: 'image/x-icon',
    pdf: 'application/pdf',
    doc: 'application/msword',
    docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
    xls: 'application/vnd.ms-excel',
    xlsx: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
    ppt: 'application/vnd.ms-powerpoint',
    pptx: 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
    txt: 'text/plain',
    md: 'text/markdown',
    csv: 'text/csv',
    json: 'application/json',
    xml: 'application/xml',
    zip: 'application/zip',
    mp3: 'audio/mpeg',
    wav: 'audio/wav',
    mp4: 'video/mp4',
    webm: 'video/webm'
  }
  return mimeMap[normalized] ?? fallback
}

/**
 * Parse ISO timestamp string to Unix timestamp (milliseconds)
 *
 * @param isoString - ISO 8601 timestamp string or undefined
 * @returns Unix timestamp in milliseconds
 */
export function parseTimestamp(isoString: string | null | undefined): number {
  if (isoString == null) return Date.now() // handles both null and undefined
  if (!isoString) return Date.now() // handles empty string

  const parsed = new Date(isoString).getTime()
  return isNaN(parsed) ? Date.now() : parsed
}

/**
 * Build block lookup map from message_blocks table
 *
 * Creates a Map of blockId → block for fast lookup during message transformation.
 *
 * @param blocks - All blocks from message_blocks table
 * @returns Map for O(1) block lookup
 */
export function buildBlockLookup(blocks: OldBlock[]): Map<string, OldBlock> {
  const lookup = new Map<string, OldBlock>()
  for (const block of blocks) {
    lookup.set(block.id, block)
  }
  return lookup
}

/**
 * Resolve block IDs to actual block data
 *
 * @param blockIds - Array of block IDs from message.blocks
 * @param blockLookup - Map of blockId → block
 * @returns Array of resolved blocks (missing blocks are skipped)
 */
export function resolveBlocks(blockIds: string[], blockLookup: Map<string, OldBlock>): OldBlock[] {
  const resolved: OldBlock[] = []
  for (const id of blockIds) {
    const block = blockLookup.get(id)
    if (block) {
      resolved.push(block)
    }
  }
  return resolved
}
