/**
 * Timeline mappers — Phase 2
 *
 * Pure functions for transforming DB rows (session_messages + session_activities)
 * into the unified TimelineMessage[] format.
 *
 * This module runs in the **main process** (session-timeline-service) and can
 * also be imported from the renderer via the shared path alias.
 *
 * The logic is extracted from:
 *   - src/renderer/src/lib/opencode-transcript.ts
 *   - src/renderer/src/lib/codex-timeline.ts
 * Those files are retained for backward compatibility until Phase 3.
 */

import type { MessagePart } from '../types/opencode'
import type { StreamingPart, ToolUseInfo, TimelineMessage } from './timeline-types'
import { classifyCodexItem } from './codex-classify'

// Re-export for convenience
export type { StreamingPart, ToolUseInfo, TimelineMessage }

// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------

function asRecord(value: unknown): Record<string, unknown> | null {
  return typeof value === 'object' && value !== null && !Array.isArray(value)
    ? (value as Record<string, unknown>)
    : null
}

function asString(value: unknown): string | undefined {
  return typeof value === 'string' ? value : undefined
}

function toNumber(value: unknown): number | undefined {
  return typeof value === 'number' && Number.isFinite(value) ? value : undefined
}

function toTimestampMs(value: unknown): number | undefined {
  if (typeof value === 'number' && Number.isFinite(value)) return value
  if (typeof value !== 'string') return undefined
  const numeric = Number(value)
  if (Number.isFinite(numeric)) return numeric
  const parsed = Date.parse(value)
  return Number.isNaN(parsed) ? undefined : parsed
}

function toIsoTimestamp(value: unknown): string | undefined {
  const timestamp = toTimestampMs(value)
  return timestamp === undefined ? undefined : new Date(timestamp).toISOString()
}

function mapRole(value: unknown): 'user' | 'assistant' | 'system' {
  if (value === 'user' || value === 'assistant' || value === 'system') return value
  return 'assistant'
}

function mapToolStatus(value: unknown): 'pending' | 'running' | 'success' | 'error' {
  switch (value) {
    case 'pending':
      return 'pending'
    case 'running':
      return 'running'
    case 'completed':
    case 'success':
      return 'success'
    case 'error':
      return 'error'
    default:
      return 'running'
  }
}

function stringifyValue(value: unknown): string | undefined {
  if (value === undefined || value === null) return undefined
  if (typeof value === 'string') return value
  try {
    return JSON.stringify(value)
  } catch {
    return String(value)
  }
}

function parseJson<T>(value: string | null): T | null {
  if (!value) return null
  try {
    return JSON.parse(value) as T
  } catch {
    return null
  }
}

/** Check if a user message is a synthetic injection that should not appear in the timeline. */
function isSyntheticUserMessage(content: string): boolean {
  // Strip leading whitespace and any XML-like tags (e.g. <system-reminder>)
  // that Claude Code may prepend to synthetic messages.
  const trimmed = content.trimStart().replace(/^(?:<[^>]+>\s*)+/, '')
  return (
    trimmed.startsWith('This session is being continued from a previous conversation') ||
    trimmed.startsWith('Here is a summary of the conversation so far') ||
    trimmed.startsWith('Base directory for this skill:') ||
    trimmed.startsWith('<local-command-stdout>') ||
    trimmed.startsWith('<system-reminder>')
  )
}

/**
 * Strip the Phase 22A Field Context / Phase 21 Worktree Context envelope from a
 * user message so the bubble shows only what the user actually typed.
 *
 * The envelope is prefixed in src/main/ipc/agent-handlers.ts and looks like:
 *
 *   [Field Context — as of HH:MM:SS]
 *   ...
 *   ## Worktree
 *   ...
 *   [User Message]
 *   <actual user text>
 *
 * or the legacy:
 *
 *   [Worktree Context]
 *   ...
 *   [User Message]
 *   <actual user text>
 *
 * SDK transcripts capture the full envelope (we can't avoid that — three SDKs
 * disallow editing the user turn after send), so we strip on read for display.
 * The injected envelope is still observable in `agent:prompt` logs and in the
 * Field Context Debug panel for verification.
 */
export function stripInjectedContextEnvelope(content: string): string {
  if (!content) return content
  if (
    !content.startsWith('[Field Context') &&
    !content.startsWith('[Worktree Context]')
  ) {
    return content
  }
  // Anchor on the literal closing marker the prefix templates always emit.
  const marker = '\n[User Message]\n'
  const idx = content.indexOf(marker)
  if (idx === -1) return content
  return content.slice(idx + marker.length)
}

// ---------------------------------------------------------------------------
// Part mappers (from opencode-transcript.ts)
// ---------------------------------------------------------------------------

export function extractTextContentFromParts(parts: unknown): string {
  if (!Array.isArray(parts)) return ''
  return parts
    .map((part) => {
      const record = asRecord(part)
      if (!record || record.type !== 'text') return ''
      return asString(record.text) ?? ''
    })
    .join('')
}

export function mapPartToStreamingPart(part: unknown, index = 0): StreamingPart | null {
  const record = asRecord(part)
  if (!record) return null

  const type = asString(record.type)
  if (!type) return null

  if (type === 'text') {
    const text = asString(record.text)
    return text ? { type: 'text', text } : null
  }

  if (type === 'tool_use') {
    const toolUse = asRecord(record.toolUse)
    if (!toolUse) return null
    return {
      type: 'tool_use',
      toolUse: {
        id: asString(toolUse.id) ?? `tool-${index}`,
        name: asString(toolUse.name) ?? 'Unknown',
        input: asRecord(toolUse.input) ?? {},
        status: mapToolStatus(toolUse.status),
        startTime: toTimestampMs(toolUse.startTime) ?? Date.now(),
        endTime: toTimestampMs(toolUse.endTime),
        output: stringifyValue(toolUse.output),
        error: asString(toolUse.error)
      }
    }
  }

  if (type === 'tool') {
    const state = asRecord(record.state) ?? {}
    const stateTime = asRecord(state.time) ?? {}
    return {
      type: 'tool_use',
      toolUse: {
        id: asString(record.callID) ?? asString(record.id) ?? `tool-${index}`,
        name: asString(record.tool) ?? asString(record.name) ?? 'Unknown',
        input: asRecord(state.input) ?? {},
        status: mapToolStatus(state.status),
        startTime: toTimestampMs(stateTime.start) ?? Date.now(),
        endTime: toTimestampMs(stateTime.end),
        output: stringifyValue(state.output),
        error: stringifyValue(state.error)
      }
    }
  }

  if (type === 'subtask') {
    const rawParts = Array.isArray(record.parts) ? record.parts : []
    const parts = rawParts
      .map((rawPart, partIndex) => mapPartToStreamingPart(rawPart, partIndex))
      .filter((mappedPart): mappedPart is StreamingPart => mappedPart !== null)
    return {
      type: 'subtask',
      subtask: {
        id: asString(record.id) ?? `subtask-${index}`,
        sessionID: asString(record.sessionID) ?? '',
        prompt: asString(record.prompt) ?? '',
        description: asString(record.description) ?? '',
        agent: asString(record.agent) ?? 'unknown',
        parts,
        status:
          record.status === 'completed' || record.status === 'error'
            ? (record.status as 'completed' | 'error')
            : 'running'
      }
    }
  }

  if (type === 'step-start' || type === 'step_start') {
    return { type: 'step_start', stepStart: { snapshot: asString(record.snapshot) } }
  }

  if (type === 'step-finish' || type === 'step_finish') {
    const tokens = asRecord(record.tokens)
    return {
      type: 'step_finish',
      stepFinish: {
        reason: asString(record.reason) ?? '',
        cost: toNumber(record.cost) ?? 0,
        tokens: {
          input: toNumber(tokens?.input) ?? 0,
          output: toNumber(tokens?.output) ?? 0,
          reasoning: toNumber(tokens?.reasoning) ?? 0
        }
      }
    }
  }

  if (type === 'reasoning') {
    return { type: 'reasoning', reasoning: asString(record.text) ?? '' }
  }

  if (type === 'compaction') {
    return { type: 'compaction', compactionAuto: record.auto === true }
  }

  return null
}

// ---------------------------------------------------------------------------
// OpenCode / Claude Code transcript mapper
// (from opencode-transcript.ts: mapOpencodeMessagesToSessionViewMessages)
// ---------------------------------------------------------------------------

interface MappedMessage {
  message: TimelineMessage
  sortTime?: number
  originalIndex: number
}

function mapRawMessage(rawMessage: unknown, index: number): MappedMessage {
  const messageRecord = asRecord(rawMessage)
  const info = asRecord(messageRecord?.info)

  const id = asString(info?.id) ?? asString(messageRecord?.id) ?? `message-${index}`
  const role = mapRole(info?.role ?? messageRecord?.role)

  const rawParts = Array.isArray(messageRecord?.parts) ? messageRecord.parts : []
  const mappedParts = rawParts
    .map((part, partIndex) => mapPartToStreamingPart(part, partIndex))
    .filter((part): part is StreamingPart => part !== null)

  const fileAttachments: MessagePart[] = rawParts
    .filter((p) => {
      const r = asRecord(p)
      return r && asString(r.type) === 'file'
    })
    .map((p) => {
      const r = asRecord(p)!
      return {
        type: 'file' as const,
        mime: asString(r.mime) ?? '',
        url: asString(r.url) ?? '',
        filename: asString(r.filename)
      }
    })
    .filter((f) => f.url)

  const rawContent =
    extractTextContentFromParts(rawParts) ||
    asString(info?.content) ||
    asString(messageRecord?.content) ||
    ''
  const content = role === 'user' ? stripInjectedContextEnvelope(rawContent) : rawContent

  const sortTime =
    toTimestampMs(info?.time && asRecord(info.time)?.created) ??
    toTimestampMs(info?.createdAt) ??
    toTimestampMs(info?.created_at) ??
    toTimestampMs(messageRecord?.timestamp)

  const timestamp =
    toIsoTimestamp(info?.time && asRecord(info.time)?.created) ??
    toIsoTimestamp(info?.createdAt) ??
    toIsoTimestamp(info?.created_at) ??
    toIsoTimestamp(messageRecord?.timestamp) ??
    new Date(0).toISOString()

  return {
    message: {
      id,
      role,
      content,
      timestamp,
      ...(messageRecord?.steered === true || info?.steered === true ? { steered: true } : {}),
      parts: mappedParts.length > 0 ? mappedParts : undefined,
      ...(role === 'user' && fileAttachments.length > 0
        ? { attachments: fileAttachments }
        : {})
    },
    sortTime,
    originalIndex: index
  }
}

/**
 * Map raw OpenCode/Claude Code messages to unified timeline messages.
 * Sorts by creation time, deduplicates user messages, and filters compaction summaries.
 */
export function mapRawTranscriptToTimeline(messages: unknown[]): TimelineMessage[] {
  if (!Array.isArray(messages)) return []

  const sorted = messages
    .map((message, index) => mapRawMessage(message, index))
    .sort((a, b) => {
      if (a.sortTime !== undefined && b.sortTime !== undefined && a.sortTime !== b.sortTime) {
        return a.sortTime - b.sortTime
      }
      if (a.sortTime !== undefined && b.sortTime === undefined) return -1
      if (a.sortTime === undefined && b.sortTime !== undefined) return 1
      return a.originalIndex - b.originalIndex
    })
    .map((item) => item.message)

  const seen = new Set<string>()
  return sorted.filter((msg) => {
    if (msg.role !== 'user') return true
    if (isSyntheticUserMessage(msg.content)) return false
    const key = msg.content.trim()
    if (seen.has(key)) return false
    seen.add(key)
    return true
  })
}

// ---------------------------------------------------------------------------
// DB row → TimelineMessage mapper
// (from codex-timeline.ts: mapDbSessionMessagesToOpenCodeMessages)
// ---------------------------------------------------------------------------

export interface DbSessionMessage {
  id: string
  session_id: string
  role: 'user' | 'assistant' | 'system'
  content: string
  opencode_message_id: string | null
  opencode_message_json: string | null
  opencode_parts_json: string | null
  opencode_timeline_json: string | null
  created_at: string
}

export interface DbSessionActivity {
  id: string
  session_id: string
  agent_session_id: string | null
  thread_id: string | null
  turn_id: string | null
  item_id: string | null
  request_id: string | null
  kind: string
  tone: string
  summary: string
  payload_json: string | null
  sequence: number | null
  created_at: string
}

export function mapDbRowsToTimelineMessages(messages: DbSessionMessage[]): TimelineMessage[] {
  return messages
    .filter((m) => !(m.role === 'user' && isSyntheticUserMessage(m.content)))
    .map((message) => {
    const parsedMessage = parseJson<Record<string, unknown>>(message.opencode_message_json)
    const parsedParts = parseJson<unknown[]>(message.opencode_parts_json)
    const parts = Array.isArray(parsedParts)
      ? parsedParts
          .map((part, index) => mapPartToStreamingPart(part, index))
          .filter((part): part is StreamingPart => part !== null)
      : undefined

    // Extract usage + model identity from the raw SDK message so the renderer
    // can rehydrate context-window state when reopening old sessions.
    let usage: TimelineMessage['usage']
    let modelRef: TimelineMessage['modelRef']
    if (message.role === 'assistant' && parsedMessage) {
      const info = isPlainRecord(parsedMessage.info) ? parsedMessage.info : undefined
      const innerMessage = isPlainRecord(parsedMessage.message) ? parsedMessage.message : undefined
      const rawUsage =
        (isPlainRecord(parsedMessage.usage) ? parsedMessage.usage : undefined) ??
        (info && isPlainRecord(info.usage) ? info.usage : undefined) ??
        (innerMessage && isPlainRecord(innerMessage.usage) ? innerMessage.usage : undefined)

      if (rawUsage) {
        const input = pickNumber(rawUsage, 'input_tokens', 'inputTokens', 'input')
        const output = pickNumber(rawUsage, 'output_tokens', 'outputTokens', 'output')
        const cacheRead = pickNumber(
          rawUsage,
          'cache_read_input_tokens',
          'cacheReadInputTokens',
          'cache_read',
          'cacheRead'
        )
        const cacheWrite = pickNumber(
          rawUsage,
          'cache_creation_input_tokens',
          'cacheCreationInputTokens',
          'cache_creation',
          'cacheCreation',
          'cache_write',
          'cacheWrite'
        )
        const reasoning = pickNumber(rawUsage, 'reasoning_tokens', 'reasoningTokens', 'reasoning')
        if (input || output || cacheRead || cacheWrite || reasoning) {
          usage = { input, output, cacheRead, cacheWrite, reasoning }
        }
      }

      const providerID = firstString(
        parsedMessage.providerID,
        info?.providerID,
        info?.provider_id,
        innerMessage?.provider
      )
      const modelID = firstString(
        parsedMessage.modelID,
        parsedMessage.model,
        info?.modelID,
        info?.model_id,
        info?.model,
        innerMessage?.model
      )
      if (providerID && modelID) {
        modelRef = { providerID, modelID }
      } else if (modelID) {
        modelRef = { providerID: 'anthropic', modelID }
      }
    }

    return {
      id: message.opencode_message_id ?? message.id,
      role: message.role,
      content:
        message.role === 'user'
          ? stripInjectedContextEnvelope(message.content)
          : message.content,
      timestamp: message.created_at,
      ...(parsedMessage?.steered === true ? { steered: true } : {}),
      parts: parts && parts.length > 0 ? parts : undefined,
      ...(usage ? { usage } : {}),
      ...(modelRef ? { modelRef } : {})
    }
  })
}

function isPlainRecord(value: unknown): value is Record<string, unknown> {
  return typeof value === 'object' && value !== null && !Array.isArray(value)
}

function pickNumber(
  source: Record<string, unknown>,
  ...keys: string[]
): number {
  for (const key of keys) {
    const raw = source[key]
    if (typeof raw === 'number' && Number.isFinite(raw)) return raw
    if (typeof raw === 'string') {
      const parsed = Number(raw)
      if (Number.isFinite(parsed)) return parsed
    }
  }
  return 0
}

function firstString(...values: unknown[]): string | undefined {
  for (const v of values) {
    if (typeof v === 'string' && v.length > 0) return v
  }
  return undefined
}

// ---------------------------------------------------------------------------
// Codex timeline assembly helpers
// (from codex-timeline.ts)
// ---------------------------------------------------------------------------

function extractAssistantTurnId(messageId: string): string | null {
  const match = messageId.match(/^(.*):assistant(?::.*)?$/)
  return match?.[1] ?? null
}

function extractUserTurnId(messageId: string): string | null {
  const match = messageId.match(/^(.*):user(?::.*)?$/)
  return match?.[1] ?? null
}

function extractRoleOrdinal(messageId: string, role: 'user' | 'assistant'): number {
  const match = messageId.match(new RegExp(`^[^]*:${role}(?::(.+))?$`))
  if (!match) return 0
  if (!match[1]) return 1
  const numericSuffix = Number.parseInt(match[1], 10)
  return Number.isFinite(numericSuffix) ? Math.max(1, numericSuffix) : 2
}

function getOrderedActivityTurnIds(activityRows: DbSessionActivity[]): string[] {
  return [
    ...new Set(
      [...activityRows]
        .sort((left, right) => {
          const leftTime = Date.parse(left.created_at)
          const rightTime = Date.parse(right.created_at)
          if (leftTime !== rightTime) return leftTime - rightTime
          return left.id.localeCompare(right.id)
        })
        .map((activity) => activity.turn_id)
        .filter((turnId): turnId is string => typeof turnId === 'string' && turnId.length > 0)
    )
  ]
}

function normalizeCodexMessageRows(
  messages: DbSessionMessage[],
  activityRows: DbSessionActivity[]
): DbSessionMessage[] {
  const orderedMessages = [...messages].sort((left, right) => {
    const leftTime = Date.parse(left.created_at)
    const rightTime = Date.parse(right.created_at)
    if (leftTime !== rightTime) return leftTime - rightTime
    return left.id.localeCompare(right.id)
  })

  const orderedTurnIds = getOrderedActivityTurnIds(activityRows)
  if (orderedTurnIds.length === 0) return orderedMessages

  const turnIndexById = new Map(orderedTurnIds.map((turnId, index) => [turnId, index]))
  let currentTurnIndex = -1
  let currentTurnId: string | null = null
  let assistantCountWithinTurn = 0
  let userCountWithinTurn = 0

  return orderedMessages.map((message) => {
    const messageId = message.opencode_message_id
    const canonicalTurnId =
      typeof messageId === 'string'
        ? message.role === 'assistant'
          ? extractAssistantTurnId(messageId)
          : message.role === 'user'
            ? extractUserTurnId(messageId)
            : null
        : null

    if (canonicalTurnId) {
      currentTurnId = canonicalTurnId
      currentTurnIndex = turnIndexById.get(canonicalTurnId) ?? currentTurnIndex
      if (message.role === 'user') {
        userCountWithinTurn = extractRoleOrdinal(messageId!, 'user')
        assistantCountWithinTurn = 0
      } else if (message.role === 'assistant') {
        assistantCountWithinTurn = extractRoleOrdinal(messageId!, 'assistant')
      }
      return message
    }

    if (message.role === 'user') {
      if (!currentTurnId || userCountWithinTurn > 0) {
        currentTurnIndex += 1
        currentTurnId = orderedTurnIds[currentTurnIndex] ?? currentTurnId
        userCountWithinTurn = 0
      }
      if (!currentTurnId) return message
      assistantCountWithinTurn = 0
      userCountWithinTurn += 1
      return {
        ...message,
        opencode_message_id:
          userCountWithinTurn === 1
            ? `${currentTurnId}:user`
            : `${currentTurnId}:user:${userCountWithinTurn}`
      }
    }

    if (message.role === 'assistant') {
      const turnId = currentTurnId ?? orderedTurnIds[Math.max(currentTurnIndex, 0)]
      if (!turnId) return message
      const mid =
        assistantCountWithinTurn === 0
          ? `${turnId}:assistant`
          : `${turnId}:assistant:${assistantCountWithinTurn + 1}`
      assistantCountWithinTurn += 1
      return { ...message, opencode_message_id: mid }
    }

    return message
  })
}

/** @internal — exported for tests only. */
export function parseToolPartFromActivity(activity: DbSessionActivity): StreamingPart | null {
  const payload = parseJson<Record<string, unknown>>(activity.payload_json)
  const item =
    payload && typeof payload.item === 'object'
      ? (payload.item as Record<string, unknown>)
      : null

  // Codex shape detection: codex items have type ∈ {commandExecution,
  // fileChange, mcpToolCall, webSearch, agentMessage, reasoning,
  // userMessage}. We classify them through the shared classifier so the
  // durable timeline matches what the streaming overlay rendered.
  const codexItemType = item && typeof item.type === 'string' ? item.type : undefined
  const isCodexShape =
    codexItemType === 'commandExecution' ||
    codexItemType === 'fileChange' ||
    codexItemType === 'mcpToolCall' ||
    codexItemType === 'webSearch'

  if (item && isCodexShape) {
    const classified = classifyCodexItem(item)
    if (classified) {
      const status: 'running' | 'success' | 'error' =
        activity.kind === 'tool.completed'
          ? 'success'
          : activity.kind === 'tool.failed'
            ? 'error'
            : 'running'
      return {
        type: 'tool_use',
        toolUse: {
          id: activity.item_id ?? activity.id,
          name: classified.tool,
          input: classified.input ?? {},
          status,
          startTime: Date.parse(activity.created_at) || Date.now(),
          endTime:
            activity.kind === 'tool.completed' || activity.kind === 'tool.failed'
              ? Date.parse(activity.created_at) || Date.now()
              : undefined,
          output:
            activity.kind === 'tool.completed' && classified.output
              ? classified.output
              : undefined,
          error:
            activity.kind === 'tool.failed'
              ? (stringifyValue(classified.output) ?? activity.summary)
              : undefined
        }
      }
    }
  }

  // Generic / claude-code shape — preserve previous behavior.
  const toolName =
    (typeof item?.toolName === 'string' && item.toolName) ||
    (typeof item?.name === 'string' && item.name) ||
    (typeof item?.type === 'string' && item.type) ||
    'unknown'
  const rawInput =
    item?.input && typeof item.input === 'object' && !Array.isArray(item.input)
      ? (item.input as Record<string, unknown>)
      : {}
  const input = Array.isArray(item?.changes) ? { ...rawInput, changes: item.changes } : rawInput
  const output =
    item?.output ?? payload?.output ?? item?.aggregatedOutput ?? payload?.aggregatedOutput

  return {
    type: 'tool_use',
    toolUse: {
      id: activity.item_id ?? activity.id,
      name: toolName,
      input,
      status:
        activity.kind === 'tool.completed'
          ? 'success'
          : activity.kind === 'tool.failed'
            ? 'error'
            : 'running',
      startTime: Date.parse(activity.created_at) || Date.now(),
      endTime:
        activity.kind === 'tool.completed' || activity.kind === 'tool.failed'
          ? Date.parse(activity.created_at) || Date.now()
          : undefined,
      output: activity.kind === 'tool.completed' ? stringifyValue(output) : undefined,
      error:
        activity.kind === 'tool.failed' ? (stringifyValue(output) ?? activity.summary) : undefined
    }
  }
}

function parsePlanPartFromActivity(
  activity: DbSessionActivity,
  resolvedRequestIds?: Set<string>
): StreamingPart | null {
  const payload = parseJson<Record<string, unknown>>(activity.payload_json)
  if (activity.kind === 'plan.ready') {
    const plan =
      (typeof payload?.plan === 'string' && payload.plan.trim()) ||
      (typeof payload?.planContent === 'string' && payload.planContent.trim()) ||
      ''
    if (!plan) return null

    const toolUseId =
      (typeof payload?.toolUseID === 'string' && payload.toolUseID) ||
      activity.item_id ||
      activity.request_id ||
      activity.id

    // If a matching plan.resolved activity exists, the plan has been
    // approved/rejected — surface the card with success status so the
    // "Requires Approval" badge disappears and the card collapses.
    const reqId = activity.request_id ?? activity.id
    const isResolved = resolvedRequestIds?.has(reqId) ?? false

    return {
      type: 'tool_use',
      toolUse: {
        id: toolUseId,
        name: 'ExitPlanMode',
        input: { plan },
        status: isResolved ? 'success' : 'pending',
        startTime: Date.parse(activity.created_at) || Date.now(),
        ...(isResolved
          ? { endTime: Date.parse(activity.created_at) || Date.now() }
          : {})
      }
    }
  }

  if (
    activity.kind === 'session.info' &&
    payload?.kind === 'update_plan' &&
    Array.isArray(payload.todos)
  ) {
    const toolUseId =
      (typeof payload.callID === 'string' && payload.callID) ||
      activity.item_id ||
      activity.request_id ||
      activity.id

    return {
      type: 'tool_use',
      toolUse: {
        id: toolUseId,
        name: 'update_plan',
        input: { todos: payload.todos },
        status: 'success',
        startTime: Date.parse(activity.created_at) || Date.now(),
        endTime: Date.parse(activity.created_at) || Date.now()
      }
    }
  }

  return null
}

function upsertToolPart(
  parts: StreamingPart[] | undefined,
  nextPart: StreamingPart
): StreamingPart[] {
  const existingParts = parts ? [...parts] : []
  const nextToolId = nextPart.toolUse?.id
  const partIndex = existingParts.findIndex(
    (part) => part.type === 'tool_use' && part.toolUse?.id === nextToolId
  )
  if (partIndex >= 0) {
    existingParts[partIndex] = nextPart
  } else {
    existingParts.push(nextPart)
  }
  return existingParts
}

function normalizeCodexOpenCodeMessages(
  messages: TimelineMessage[],
  activityRows: DbSessionActivity[]
): TimelineMessage[] {
  const orderedTurnIds = getOrderedActivityTurnIds(activityRows)
  if (orderedTurnIds.length === 0) return messages

  const turnIndexById = new Map(orderedTurnIds.map((turnId, index) => [turnId, index]))
  let currentTurnIndex = -1
  let currentTurnId: string | null = null
  let assistantCountWithinTurn = 0
  let userCountWithinTurn = 0

  return messages.map((message) => {
    const canonicalTurnId =
      message.role === 'assistant'
        ? extractAssistantTurnId(message.id)
        : message.role === 'user'
          ? extractUserTurnId(message.id)
          : null

    if (canonicalTurnId) {
      currentTurnId = canonicalTurnId
      currentTurnIndex = turnIndexById.get(canonicalTurnId) ?? currentTurnIndex
      if (message.role === 'user') {
        userCountWithinTurn = extractRoleOrdinal(message.id, 'user')
        assistantCountWithinTurn = 0
      } else if (message.role === 'assistant') {
        assistantCountWithinTurn = extractRoleOrdinal(message.id, 'assistant')
      }
      return message
    }

    if (message.role === 'user') {
      if (!currentTurnId || userCountWithinTurn > 0) {
        currentTurnIndex += 1
        currentTurnId = orderedTurnIds[currentTurnIndex] ?? currentTurnId
        userCountWithinTurn = 0
      }
      if (!currentTurnId) return message
      assistantCountWithinTurn = 0
      userCountWithinTurn += 1
      return {
        ...message,
        id:
          userCountWithinTurn === 1
            ? `${currentTurnId}:user`
            : `${currentTurnId}:user:${userCountWithinTurn}`
      }
    }

    if (message.role === 'assistant') {
      const turnId = currentTurnId ?? orderedTurnIds[Math.max(currentTurnIndex, 0)]
      if (!turnId) return message
      const mid =
        assistantCountWithinTurn === 0
          ? `${turnId}:assistant`
          : `${turnId}:assistant:${assistantCountWithinTurn + 1}`
      assistantCountWithinTurn += 1
      return { ...message, id: mid }
    }

    return message
  })
}

function mergeCodexActivityMessages(
  baseMessages: TimelineMessage[],
  activityRows: DbSessionActivity[]
): TimelineMessage[] {
  const normalizedBaseMessages = normalizeCodexOpenCodeMessages(baseMessages, activityRows)
  const mergedMessages = normalizedBaseMessages.map((message) => ({
    ...message,
    parts: message.parts ? [...message.parts] : undefined
  }))
  const knownToolIds = new Set(
    mergedMessages.flatMap((message) =>
      (message.parts ?? [])
        .filter((part) => part.type === 'tool_use' && !!part.toolUse?.id)
        .map((part) => part.toolUse!.id)
    )
  )
  const firstAssistantIndexByTurnId = new Map<string, number>()
  const turnOrder: string[] = []

  mergedMessages.forEach((message, index) => {
    const turnId =
      message.role === 'assistant'
        ? extractAssistantTurnId(message.id)
        : (message.id.match(/^(.*):user(?::.*)?$/)?.[1] ?? null)
    if (!turnId) return
    if (!turnOrder.includes(turnId)) turnOrder.push(turnId)
    if (message.role === 'assistant' && !firstAssistantIndexByTurnId.has(turnId)) {
      firstAssistantIndexByTurnId.set(turnId, index)
    }
  })

  const anchoredSyntheticByTurnId = new Map<
    string,
    Array<TimelineMessage & { syntheticOrder: number }>
  >()
  const unanchoredSynthetic: Array<TimelineMessage & { syntheticOrder: number }> = []

  const sortedActivities = [...activityRows].sort((left, right) => {
    const leftTime = Date.parse(left.created_at)
    const rightTime = Date.parse(right.created_at)
    if (leftTime !== rightTime) return leftTime - rightTime
    return left.id.localeCompare(right.id)
  })

  // Pre-scan for plan.resolved activities so plan.ready cards rendered later
  // can be marked as resolved (no more "Requires Approval" badge).
  const resolvedRequestIds = new Set<string>()
  for (const activity of sortedActivities) {
    if (activity.kind === 'plan.resolved' && activity.request_id) {
      resolvedRequestIds.add(activity.request_id)
    }
  }

  for (const activity of sortedActivities) {
    // Skip plan.resolved itself — it's a marker, not a renderable activity.
    if (activity.kind === 'plan.resolved') continue
    const activityPart = activity.kind.startsWith('tool.')
      ? parseToolPartFromActivity(activity)
      : parsePlanPartFromActivity(activity, resolvedRequestIds)
    if (!activityPart?.toolUse) continue

    const toolId = activityPart.toolUse.id
    if (knownToolIds.has(toolId)) continue

    const turnId = activity.turn_id
    const syntheticId = turnId ? `${turnId}:tool:${toolId}` : `tool:${toolId}`
    const targetCollection = turnId
      ? (anchoredSyntheticByTurnId.get(turnId) ?? [])
      : unanchoredSynthetic
    let target = targetCollection.find((message) => message.id === syntheticId)
    if (!target) {
      target = {
        id: syntheticId,
        role: 'assistant',
        content: '',
        timestamp: activity.created_at,
        parts: [],
        syntheticOrder: targetCollection.length
      }
      targetCollection.push(target)
      if (turnId) anchoredSyntheticByTurnId.set(turnId, targetCollection)
    }
    target.parts = upsertToolPart(target.parts, activityPart)
  }

  const injectedTurns = new Set<string>()
  const orderedMessages: TimelineMessage[] = []

  // For each turn we build a sorted batch of [real assistant messages, synthetic
  // tool/plan/question messages] interleaved by timestamp. Synthetics carry
  // their activity's `created_at`, real messages carry their session_message
  // timestamp. When ties occur we prefer real-before-synthetic so a plan
  // card emitted at turn-end lands AFTER the assistant text rather than
  // ahead of it (the legacy hardcoded "anchor before first assistant" rule
  // got this wrong for plan.ready and AskUserQuestion). User messages are
  // walked separately and pushed in their original document position so
  // they always precede their turn's assistants.
  function buildTurnBatch(turnId: string): TimelineMessage[] {
    const synthetics = anchoredSyntheticByTurnId.get(turnId) ?? []
    const reals = mergedMessages.filter(
      (m) => m.role === 'assistant' && extractAssistantTurnId(m.id) === turnId
    )
    if (synthetics.length === 0) return reals
    const tagged: Array<{ msg: TimelineMessage; ts: number; isSynthetic: boolean }> = [
      ...reals.map((m) => ({
        msg: m,
        ts: Date.parse(m.timestamp) || 0,
        isSynthetic: false
      })),
      ...synthetics.map((m) => ({
        msg: m,
        ts: Date.parse(m.timestamp) || 0,
        isSynthetic: true
      }))
    ]
    tagged.sort((a, b) => {
      if (a.ts !== b.ts) return a.ts - b.ts
      // Tie-break: real before synthetic. This puts post-turn synthetics
      // (plan.ready, end-of-turn cards) after the assistant text when they
      // share a timestamp.
      if (a.isSynthetic !== b.isSynthetic) return a.isSynthetic ? 1 : -1
      return 0
    })
    return tagged.map((t) => t.msg)
  }

  mergedMessages.forEach((message, index) => {
    const turnId =
      message.role === 'assistant'
        ? extractAssistantTurnId(message.id)
        : (message.id.match(/^(.*):user(?::.*)?$/)?.[1] ?? null)

    if (
      turnId &&
      message.role === 'assistant' &&
      firstAssistantIndexByTurnId.get(turnId) === index &&
      !injectedTurns.has(turnId)
    ) {
      // Push the full sorted batch for this turn (real + synthetic).
      orderedMessages.push(...buildTurnBatch(turnId))
      injectedTurns.add(turnId)
      return
    }

    if (turnId && message.role === 'assistant' && injectedTurns.has(turnId)) {
      // Already pushed as part of the turn batch above.
      return
    }

    orderedMessages.push(message)
  })

  for (const turnId of turnOrder) {
    if (injectedTurns.has(turnId)) continue
    const syntheticMessages = anchoredSyntheticByTurnId.get(turnId)
    if (syntheticMessages && syntheticMessages.length > 0) {
      orderedMessages.push(...syntheticMessages)
    }
  }

  if (unanchoredSynthetic.length > 0) {
    orderedMessages.push(...unanchoredSynthetic)
  }

  return orderedMessages
}

/**
 * Derive a unified timeline from Codex session data.
 * Combines session_messages with session_activities to produce tool-enriched timeline.
 */
export function deriveCodexTimeline(
  messageRows: DbSessionMessage[],
  activityRows: DbSessionActivity[]
): TimelineMessage[] {
  const normalizedMessages = normalizeCodexMessageRows(messageRows, activityRows)
  return mergeCodexActivityMessages(
    mapDbRowsToTimelineMessages(normalizedMessages),
    activityRows
  )
}

/**
 * Phase 1.4.8 (OpenCode plan parity): merge `plan.ready` activity rows into
 * an OpenCode timeline so the durable transcript shows a `ExitPlanMode`
 * tool-use card per plan turn.
 *
 * OpenCode's normal timeline is built from `mapRawTranscriptToTimeline`
 * (messages only) — activities are not consulted. Plan-mode generates the
 * plan as a regular `text` part, so without this merge the FAB pops up but
 * no card ever appears in the message stream.
 *
 * This is intentionally narrower than `mergeCodexActivityMessages`:
 *   - Only `plan.ready` activities are merged (the others are already
 *     captured by OpenCode's own tool parts).
 *   - The synthetic ExitPlanMode tool-part is appended to the matching
 *     assistant message (looked up by `item_id` === message id). If we can't
 *     match (e.g. timeline trimmed), the part is appended as a standalone
 *     synthetic assistant message anchored at the activity's timestamp so
 *     the card still appears.
 *   - Plans with a paired `plan.resolved` row render as `status: 'success'`
 *     (greyed out, no "Requires Approval" badge), same logic Codex uses.
 */
export function mergeOpenCodePlanActivities(
  messages: TimelineMessage[],
  activityRows: DbSessionActivity[]
): TimelineMessage[] {
  if (activityRows.length === 0) return messages

  const planReadyRows = activityRows.filter((row) => row.kind === 'plan.ready')
  if (planReadyRows.length === 0) return messages

  const resolvedRequestIds = new Set<string>()
  for (const row of activityRows) {
    if (row.kind === 'plan.resolved' && row.request_id) {
      resolvedRequestIds.add(row.request_id)
    }
  }

  // Index existing messages by id (the assistant message id == activity.item_id)
  const messageIndex = new Map<string, number>()
  messages.forEach((msg, idx) => messageIndex.set(msg.id, idx))

  // Track tool ids already present so we don't duplicate cards if the upstream
  // transcript ever starts surfacing ExitPlanMode itself.
  const knownToolIds = new Set<string>()
  for (const msg of messages) {
    if (!Array.isArray(msg.parts)) continue
    for (const part of msg.parts) {
      if (part.type === 'tool_use' && part.toolUse?.id) {
        knownToolIds.add(part.toolUse.id)
      }
    }
  }

  // Clone messages so we can splice parts safely
  const next = messages.map((msg) => ({
    ...msg,
    parts: msg.parts ? [...msg.parts] : undefined
  }))

  const trailing: TimelineMessage[] = []

  for (const row of planReadyRows) {
    const part = parsePlanPartFromActivity(row, resolvedRequestIds)
    if (!part?.toolUse) continue
    if (knownToolIds.has(part.toolUse.id)) continue
    knownToolIds.add(part.toolUse.id)

    // Phase 1.4.8 follow-up: PlanCard renders the full plan markdown via its
    // `content` prop, and `messageToNodes` ALSO renders any `text` part as a
    // separate text card. Without de-dup, the user sees the plan twice — once
    // in the assistant text bubble and once in the "Proposed Execution Plan"
    // approval card. Strip text parts whose content is fully contained in the
    // plan we're about to render so the card becomes the single source.
    const planText =
      typeof part.toolUse.input?.plan === 'string'
        ? (part.toolUse.input.plan as string).trim()
        : ''

    const itemId = row.item_id ?? null
    if (itemId && messageIndex.has(itemId)) {
      const targetIdx = messageIndex.get(itemId)!
      const target = next[targetIdx]
      const existingParts = target.parts ?? []

      const filteredParts = planText
        ? existingParts.filter((p) => {
            if (p.type !== 'text') return true
            const partText = (p.text ?? '').trim()
            if (!partText) return false
            // Drop the text part if its content is identical to the plan or a
            // strict prefix/suffix subset (covers cases where the SDK trims
            // trailing whitespace differently than our buffer).
            return !(planText === partText || planText.includes(partText))
          })
        : existingParts

      next[targetIdx] = {
        ...target,
        // Also clear `content` if it's the same plan text — `messageToNodes`
        // falls back to `content` when `parts` is empty (line 153).
        content:
          planText && (target.content ?? '').trim() === planText ? '' : target.content,
        parts: [...filteredParts, part]
      }
    } else {
      // Fallback: synthesize an assistant message wrapper so the card still
      // shows up. Anchored at the activity's created_at so it lands in
      // chronological order.
      trailing.push({
        id: row.id,
        role: 'assistant',
        content: '',
        timestamp: row.created_at,
        parts: [part]
      })
    }
  }

  if (trailing.length === 0) return next

  // Insertion-sort trailing rows by timestamp into next[]
  const merged = [...next, ...trailing].sort((a, b) => {
    const ta = Date.parse(a.timestamp ?? '') || 0
    const tb = Date.parse(b.timestamp ?? '') || 0
    return ta - tb
  })
  return merged
}
