import type { BoardView, CaseState, Connection, DocumentCaptureKind, Exhibit, ExhibitRelation, FolderExhibit, NotePresentation, OrganizationKind, SourceFileType, TimelineRange, Viewport } from './types'

export interface BoardPoint { x: number; y: number }

function notePresentation(value: unknown): NotePresentation { return value === 'lined_sheet' ? 'lined_sheet' : 'luggage' }

function threadControls(from: BoardPoint, to: BoardPoint, tightness = 65) {
  const tautness = Math.max(0, Math.min(100, Number(tightness) || 0)) / 100
  const distance = Math.hypot(to.x - from.x, to.y - from.y)
  const sag = (1 - tautness) * Math.min(190, Math.max(45, distance * .28))
  const c1 = { x: from.x + (to.x - from.x) / 3, y: from.y + (to.y - from.y) / 3 + sag }
  const c2 = { x: from.x + (to.x - from.x) * 2 / 3, y: from.y + (to.y - from.y) * 2 / 3 + sag }
  return { c1, c2 }
}

function cubicPoint(from: BoardPoint, c1: BoardPoint, c2: BoardPoint, to: BoardPoint, t: number) {
  const inverse = 1 - t
  return {
    x: inverse ** 3 * from.x + 3 * inverse ** 2 * t * c1.x + 3 * inverse * t ** 2 * c2.x + t ** 3 * to.x,
    y: inverse ** 3 * from.y + 3 * inverse ** 2 * t * c1.y + 3 * inverse * t ** 2 * c2.y + t ** 3 * to.y,
  }
}

function cubicTangent(from: BoardPoint, c1: BoardPoint, c2: BoardPoint, to: BoardPoint, t: number) {
  const inverse = 1 - t
  return {
    x: 3 * inverse ** 2 * (c1.x - from.x) + 6 * inverse * t * (c2.x - c1.x) + 3 * t ** 2 * (to.x - c2.x),
    y: 3 * inverse ** 2 * (c1.y - from.y) + 6 * inverse * t * (c2.y - c1.y) + 3 * t ** 2 * (to.y - c2.y),
  }
}

const interpolatePoint = (from: BoardPoint, to: BoardPoint, t: number): BoardPoint => ({
  x: from.x + (to.x - from.x) * t,
  y: from.y + (to.y - from.y) * t,
})

type Cubic = [BoardPoint, BoardPoint, BoardPoint, BoardPoint]

function splitCubic([from, c1, c2, to]: Cubic, t: number): [Cubic, Cubic] {
  const a = interpolatePoint(from, c1, t)
  const b = interpolatePoint(c1, c2, t)
  const c = interpolatePoint(c2, to, t)
  const d = interpolatePoint(a, b, t)
  const e = interpolatePoint(b, c, t)
  const point = interpolatePoint(d, e, t)
  return [[from, a, d, point], [point, e, c, to]]
}

function cubicSegment(curve: Cubic, start: number, end: number): Cubic {
  if (start > end) {
    const [from, c1, c2, to] = cubicSegment(curve, end, start)
    return [to, c2, c1, from]
  }
  const [, afterStart] = splitCubic(curve, start)
  const relativeEnd = start >= 1 ? 1 : (end - start) / (1 - start)
  return splitCubic(afterStart, relativeEnd)[0]
}

const pathCoordinate = (value: number) => Math.round(value * 1000) / 1000

export function threadTagLateralLimit(_tightness = 65) {
  return 0
}

export function threadTagPlacement(from: BoardPoint, to: BoardPoint, tightness = 65, positionPercent = 50, lateralOffset = 0) {
  const { c1, c2 } = threadControls(from, to, tightness)
  const position = Math.max(5, Math.min(95, Number(positionPercent) || 50))
  const t = position / 100
  const point = cubicPoint(from, c1, c2, to, t)
  const maxLateralOffset = threadTagLateralLimit(tightness)
  const offset = Math.max(-maxLateralOffset, Math.min(maxLateralOffset, Number(lateralOffset) || 0))
  return { x: point.x, y: point.y, positionPercent: position, lateralOffset: offset, maxLateralOffset }
}

export function projectThreadTag(from: BoardPoint, to: BoardPoint, tightness: number, pointer: BoardPoint) {
  const { c1, c2 } = threadControls(from, to, tightness)
  let bestT = .5
  let bestDistance = Number.POSITIVE_INFINITY
  for (let index = 5; index <= 95; index += 1) {
    const t = index / 100
    const point = cubicPoint(from, c1, c2, to, t)
    const distance = (pointer.x - point.x) ** 2 + (pointer.y - point.y) ** 2
    if (distance < bestDistance) { bestDistance = distance; bestT = t }
  }
  const maxLateralOffset = threadTagLateralLimit(tightness)
  return { positionPercent: Math.round(bestT * 100), lateralOffset: 0, maxLateralOffset }
}

export function threadCurve(from: BoardPoint, to: BoardPoint, tightness = 65) {
  const { c1, c2 } = threadControls(from, to, tightness)
  const midpoint = cubicPoint(from, c1, c2, to, .5)
  return { path: `M ${from.x} ${from.y} C ${c1.x} ${c1.y}, ${c2.x} ${c2.y}, ${to.x} ${to.y}`, midpoint }
}

/** A short foreground segment that visibly enters a luggage-tag eyelet from the left. */
export function threadTagLeadIn(from: BoardPoint, to: BoardPoint, tightness = 65, positionPercent = 50) {
  const { c1, c2 } = threadControls(from, to, tightness)
  const position = Math.max(5, Math.min(95, Number(positionPercent) || 50))
  const tagT = position / 100
  const tangent = cubicTangent(from, c1, c2, to, tagT)
  const speed = Math.max(1, Math.hypot(tangent.x, tangent.y))
  const leadLength = Math.max(.0525, Math.min(.27, 63 / speed))
  const entryT = Math.max(0, Math.min(1, tagT + (tangent.x < 0 ? leadLength : -leadLength)))
  const [start, leadC1, leadC2] = cubicSegment([from, c1, c2, to], entryT, tagT)
  const end = cubicPoint(from, c1, c2, to, tagT)
  return {
    path: `M ${pathCoordinate(start.x)} ${pathCoordinate(start.y)} C ${pathCoordinate(leadC1.x)} ${pathCoordinate(leadC1.y)}, ${pathCoordinate(leadC2.x)} ${pathCoordinate(leadC2.y)}, ${pathCoordinate(end.x)} ${pathCoordinate(end.y)}`,
    start,
    end,
  }
}

export const MIN_BOARD_ZOOM = 0.45
export const MAX_BOARD_ZOOM = 1.5

export function clampBoardZoom(zoom: number) {
  return Math.max(MIN_BOARD_ZOOM, Math.min(MAX_BOARD_ZOOM, zoom))
}

export function zoomFromWheel(currentZoom: number, deltaY: number) {
  return clampBoardZoom(currentZoom - deltaY * 0.0015)
}

export function zoomFromPinch(currentZoom: number, previousDistance: number, nextDistance: number) {
  if (!Number.isFinite(previousDistance) || !Number.isFinite(nextDistance) || previousDistance <= 0 || nextDistance <= 0) return currentZoom
  return clampBoardZoom(currentZoom * nextDistance / previousDistance)
}

export function zoomViewportAt(viewport: Viewport, nextZoom: number, screenAnchor: BoardPoint): Viewport {
  if (!Number.isFinite(viewport.zoom) || viewport.zoom <= 0) throw new RangeError('Board zoom must be positive')
  const zoom = clampBoardZoom(nextZoom)
  const boardPoint = { x: (screenAnchor.x - viewport.x) / viewport.zoom, y: (screenAnchor.y - viewport.y) / viewport.zoom }
  return { x: screenAnchor.x - boardPoint.x * zoom, y: screenAnchor.y - boardPoint.y * zoom, zoom }
}

export function moveBoardPoint(origin: { x: number; y: number }, screenDelta: { x: number; y: number }, zoom: number) {
  if (!Number.isFinite(zoom) || zoom <= 0) throw new RangeError('Board zoom must be positive')
  return { x: origin.x + screenDelta.x / zoom, y: origin.y + screenDelta.y / zoom }
}

export function nextOpenBoardPosition(
  evidence: Pick<Exhibit, 'x' | 'y' | 'width' | 'height'>[],
  preferred: { x: number; y: number },
  size: { width: number; height?: number },
  bounds = { width: 2400, height: 1500 },
) {
  const height = size.height || 160
  const gap = 28
  const overlaps = (x: number, y: number) => evidence.some(item =>
    x < item.x + item.width + gap && x + size.width + gap > item.x &&
    y < item.y + item.height + gap && y + height + gap > item.y)
  const xStep = size.width + 46
  const yStep = height + 46
  for (let row = 0; row < 7; row += 1) {
    for (const column of [0, 1, -1, 2, -2, 3, -3]) {
      const x = Math.max(80, Math.min(bounds.width - size.width - 80, preferred.x + column * xStep))
      const y = Math.max(100, Math.min(bounds.height - height - 80, preferred.y + row * yStep))
      if (!overlaps(x, y)) return { x, y }
    }
  }
  return { x: Math.max(80, Math.min(bounds.width - size.width - 80, preferred.x)), y: Math.min(bounds.height - height - 80, preferred.y + evidence.length * 24) }
}

export interface BoardScreenSize { width: number; height: number }
export interface BoardScreenInsets { top?: number; right?: number; bottom?: number; left?: number }

/** Centres an exhibit in the usable portion of the board viewport. */
export function viewportCenteredOnExhibit(
  viewport: Viewport,
  exhibit: Pick<Exhibit, 'x' | 'y' | 'width' | 'height'>,
  screen: BoardScreenSize,
  insets: BoardScreenInsets = {},
): Viewport {
  if (!Number.isFinite(viewport.zoom) || viewport.zoom <= 0) throw new RangeError('Board zoom must be positive')
  const left = Math.max(0, insets.left || 0)
  const top = Math.max(0, insets.top || 0)
  const usableWidth = Math.max(1, screen.width - left - Math.max(0, insets.right || 0))
  const usableHeight = Math.max(1, screen.height - top - Math.max(0, insets.bottom || 0))
  return {
    x: left + usableWidth / 2 - (exhibit.x + exhibit.width / 2) * viewport.zoom,
    y: top + usableHeight / 2 - (exhibit.y + exhibit.height / 2) * viewport.zoom,
    zoom: viewport.zoom,
  }
}

/**
 * Finds an open board position inside the portion of the canvas the player can
 * currently see. Screen insets reserve space for board chrome such as the mobile
 * tool rail. If the visible area is too small to contain the whole exhibit, the
 * exhibit is centred so the largest useful portion remains on screen.
 */
export function nextVisibleBoardPosition(
  exhibits: Pick<Exhibit, 'x' | 'y' | 'width' | 'height'>[],
  viewport: Viewport,
  screen: BoardScreenSize,
  size: { width: number; height: number },
  insets: BoardScreenInsets = {},
  boardBounds = { width: 2400, height: 1500 },
) {
  if (!Number.isFinite(viewport.zoom) || viewport.zoom <= 0) throw new RangeError('Board zoom must be positive')
  const left = Math.max(0, insets.left || 0)
  const top = Math.max(0, insets.top || 0)
  const right = Math.max(0, insets.right || 0)
  const bottom = Math.max(0, insets.bottom || 0)
  const usableWidth = Math.max(1, screen.width - left - right)
  const usableHeight = Math.max(1, screen.height - top - bottom)
  const centre = {
    x: (left + usableWidth / 2 - viewport.x) / viewport.zoom - size.width / 2,
    y: (top + usableHeight / 2 - viewport.y) / viewport.zoom - size.height / 2,
  }
  const worldMinX = 40
  const worldMinY = 40
  const worldMaxX = Math.max(worldMinX, boardBounds.width - size.width - 40)
  const worldMaxY = Math.max(worldMinY, boardBounds.height - size.height - 40)
  const visibleMinX = Math.max(worldMinX, (left - viewport.x) / viewport.zoom)
  const visibleMinY = Math.max(worldMinY, (top - viewport.y) / viewport.zoom)
  const visibleMaxX = Math.min(worldMaxX, (screen.width - right - viewport.x) / viewport.zoom - size.width)
  const visibleMaxY = Math.min(worldMaxY, (screen.height - bottom - viewport.y) / viewport.zoom - size.height)
  const clamp = (value: number, minimum: number, maximum: number) => Math.max(minimum, Math.min(maximum, value))

  // A whole exhibit may not fit at the current zoom on a very short landscape
  // phone. In that case centring it gives the player the largest visible area.
  const preferred = {
    x: clamp(centre.x, worldMinX, worldMaxX),
    y: clamp(centre.y, worldMinY, worldMaxY),
  }
  if (visibleMinX > visibleMaxX || visibleMinY > visibleMaxY) return preferred
  preferred.x = clamp(preferred.x, visibleMinX, visibleMaxX)
  preferred.y = clamp(preferred.y, visibleMinY, visibleMaxY)

  const gap = 28
  const overlaps = (x: number, y: number) => exhibits.some(item =>
    x < item.x + item.width + gap && x + size.width + gap > item.x &&
    y < item.y + item.height + gap && y + size.height + gap > item.y)
  const axisCandidates = (origin: number, minimum: number, maximum: number, step: number) => {
    const values = [origin, minimum, maximum]
    const rings = Math.ceil((maximum - minimum) / step) + 1
    for (let ring = 1; ring <= rings; ring += 1) values.push(clamp(origin + ring * step, minimum, maximum), clamp(origin - ring * step, minimum, maximum))
    return [...new Set(values.map(value => Math.round(value * 1000) / 1000))]
  }
  const xs = axisCandidates(preferred.x, visibleMinX, visibleMaxX, size.width + 46)
  const ys = axisCandidates(preferred.y, visibleMinY, visibleMaxY, size.height + 46)
  const candidates = xs.flatMap(x => ys.map(y => ({ x, y }))).sort((a, b) =>
    Math.hypot(a.x - preferred.x, a.y - preferred.y) - Math.hypot(b.x - preferred.x, b.y - preferred.y))
  return candidates.find(candidate => !overlaps(candidate.x, candidate.y)) || preferred
}

export function panViewport(origin: Viewport, screenDelta: { x: number; y: number }): Viewport {
  return { ...origin, x: origin.x + screenDelta.x, y: origin.y + screenDelta.y }
}

export function dateValue(date: string) {
  const parsed = Date.parse(date)
  return Number.isFinite(parsed) ? parsed : 0
}

export function timelineRange(dates: string[], fallbackYear = new Date().getFullYear(), configured?: TimelineRange) {
  if (configured) {
    const start = Date.parse(`${configured.start}T00:00:00.000Z`)
    const end = Date.parse(`${configured.end}T23:59:59.999Z`)
    if (Number.isFinite(start) && Number.isFinite(end) && end > start) {
      return { startYear: Number(configured.start.slice(0, 4)), endYear: Number(configured.end.slice(0, 4)), start, end }
    }
  }
  const years = dates
    .map(date => Number(date.slice(0, 4)))
    .filter(year => Number.isFinite(year) && year >= 1 && year <= 9999)
  let startYear = years.length ? Math.min(...years) : fallbackYear - 2
  let endYear = years.length ? Math.max(...years) : startYear + 4
  if (endYear - startYear < 4) {
    const missing = 4 - (endYear - startYear)
    startYear -= Math.floor(missing / 2)
    endYear += Math.ceil(missing / 2)
  }
  return { startYear, endYear, start: Date.UTC(startYear, 0, 1), end: Date.UTC(endYear, 11, 31) }
}

export function timelinePositionPercent(date: string, range: Pick<ReturnType<typeof timelineRange>, 'start' | 'end'>) {
  if (range.end <= range.start) throw new RangeError('Timeline range must have positive duration')
  return Math.max(0, Math.min(100, ((dateValue(date) - range.start) / (range.end - range.start)) * 100))
}

export function containedIds(state: CaseState, widgetId: string) {
  return state.relations
    .filter(relation => relation.type === 'contains' && relation.fromExhibitId === widgetId)
    .sort((a, b) => (a.sortOrder || 0) - (b.sortOrder || 0))
    .map(relation => relation.toExhibitId)
}

export function folderIsOpen(folder: FolderExhibit) {
  return folder.isOpen
}

export function relationPosition(state: CaseState, relation: ExhibitRelation) {
  const target = state.exhibits.find(exhibit => exhibit.id === relation.toExhibitId)
  if (target) return { x: target.x, y: target.y }
  const folder = state.exhibits.find(exhibit => exhibit.id === relation.fromExhibitId)
  const order = relation.sortOrder || 0
  return {
    x: (folder?.x || 100) + (folder?.width || 240) + 90 + (order % 3) * 205,
    y: (folder?.y || 100) - 30 + Math.floor(order / 3) * 185,
  }
}

export function discardExhibit(state: CaseState, exhibitId: string): CaseState {
  if (!state.exhibits.some(exhibit => exhibit.id === exhibitId)) return state
  return {
    ...state,
    exhibits: state.exhibits.filter(exhibit => exhibit.id !== exhibitId),
    relations: state.relations.filter(relation => relation.fromExhibitId !== exhibitId && relation.toExhibitId !== exhibitId),
    connections: state.connections.filter(connection => connection.fromExhibitId !== exhibitId && connection.toExhibitId !== exhibitId),
    brief: {
      ...state.brief,
      concepts: state.brief.concepts.map(concept => concept.resolvedPartyExhibitId === exhibitId
        ? { ...concept, resolvedPartyExhibitId: undefined }
        : concept),
    },
  }
}

export function defaultTimelineView(range?: TimelineRange | null): BoardView {
  return { id: crypto.randomUUID(), type: 'timeline', placement: { mode: 'docked', dockEdge: 'bottom', size: 112 }, visible: true, zIndex: 0,
    rangeMode: range ? 'fixed' : 'auto', range: range || undefined }
}

type LegacyCaseState = {
  id: string
  title: string
  subtitle: string
  viewport: Viewport
  brief?: CaseState['brief']
  goals?: CaseState['goals']
  report?: CaseState['report']
  updatedAt?: string
  levelStatus?: string
  sourceTemplateVersionId?: string
  editingAllowed?: boolean
  revision?: number
  newlyVisibleDocumentIds?: string[]
  exhibits?: Exhibit[]
  views?: BoardView[]
  documents?: Array<Record<string, unknown>>
  evidence?: Array<Record<string, unknown>>
  timelineRange?: TimelineRange | null
  relations?: Array<Record<string, unknown>>
  connections?: Array<Record<string, unknown>>
}

function placement(item: Record<string, unknown>, defaults: { width: number; height: number }, index: number) {
  return { x: Number(item.x ?? 100), y: Number(item.y ?? 100), width: Number(item.width ?? defaults.width), height: Number(item.height ?? defaults.height),
    rotation: Number(item.rotation ?? 0), zIndex: Number(item.zIndex ?? index), hidden: Boolean(item.hidden) }
}

function sourceFileType(value: unknown, mimeType: unknown): SourceFileType {
  const allowed: SourceFileType[] = ['image', 'pdf', 'web_capture', 'email', 'article', 'filing', 'price_list', 'text', 'file']
  if (allowed.includes(value as SourceFileType)) return value as SourceFileType
  return String(mimeType || '').startsWith('image/') ? 'image' : mimeType === 'application/pdf' ? 'pdf' : 'file'
}

function documentCaptureKind(value: unknown): DocumentCaptureKind {
  const allowed: DocumentCaptureKind[] = ['unclassified', 'photo', 'scene', 'clipping', 'full_page']
  return allowed.includes(value as DocumentCaptureKind) ? value as DocumentCaptureKind : 'unclassified'
}

/** Normalizes current API state and upgrades disposable pre-registry browser caches. */
export function normalizeCase(input: CaseState | LegacyCaseState): CaseState {
  const state = input as LegacyCaseState
  if (Array.isArray(state.exhibits)) {
    return { id: state.id, title: state.title, subtitle: state.subtitle, viewport: state.viewport,
      relations: (state.relations || []) as unknown as ExhibitRelation[], connections: (state.connections || []) as unknown as Connection[],
      revision: Number(state.revision || 0), brief: { ...(state.brief || { body: '', concepts: [] }), checklist: state.brief?.checklist || [] },
      goals: state.goals || [],
      report: state.report,
      views: Array.isArray(state.views) && state.views.length ? state.views : [defaultTimelineView(state.timelineRange)],
      exhibits: state.exhibits.map((exhibit, index) => ({ ...exhibit,
        ...(exhibit.type === 'document' ? { captureKind: documentCaptureKind(exhibit.captureKind) } : {}),
        ...(exhibit.type === 'note' ? { presentation: notePresentation(exhibit.presentation) } : {}),
        ...placement(exhibit as unknown as Record<string, unknown>, { width: exhibit.type === 'document' ? 174 : 240, height: exhibit.type === 'document' ? 145 : 160 }, index) })),
      updatedAt: state.updatedAt, levelStatus: state.levelStatus, sourceTemplateVersionId: state.sourceTemplateVersionId, editingAllowed: state.editingAllowed,
      newlyVisibleDocumentIds: state.newlyVisibleDocumentIds || [],
    }
  }

  const legacyEvidence = state.evidence || []
  const legacyRelations = state.relations || []
  const documentPositions = new Map(legacyRelations.filter(relation => relation.type === 'contains').map(relation => [String(relation.toWidgetId), {
    x: Number((relation.config as Record<string, unknown> | undefined)?.x ?? 100), y: Number((relation.config as Record<string, unknown> | undefined)?.y ?? 100),
  }]))
  const documents: Exhibit[] = (state.documents || []).map((document, index) => ({
    id: String(document.id), type: 'document', title: String(document.title || ''),
    ...placement({ ...document, ...(documentPositions.get(String(document.id)) || {}) }, { width: 174, height: 145 }, index),
    publishedAt: String(document.publishedAt || document.date || '') || undefined, capturedAt: String(document.capturedAt || '') || undefined,
    sourceUri: String(document.sourceUri || '') || undefined, body: Array.isArray(document.body) ? document.body.map(String) : [],
    sourceCitation: String(document.sourceCitation || '') || undefined,
    regions: Array.isArray(document.regions) ? document.regions as never[] : [], assetId: String(document.assetId || '') || undefined,
    fileName: String(document.fileName || '') || undefined, mimeType: String(document.mimeType || '') || undefined,
    fileSize: document.fileSize === undefined ? undefined : Number(document.fileSize),
    fileType: sourceFileType(document.fileType, document.mimeType),
    captureKind: documentCaptureKind(document.captureKind),
    metadata: document.metadata && typeof document.metadata === 'object' ? document.metadata as Record<string, string> : {},
  } as Exhibit))
  const evidence: Exhibit[] = legacyEvidence.map((item, index) => {
    const type = item.type === 'evidence' ? 'folder' : item.type
    const common = { id: String(item.id), type, title: String(item.title || ''), content: String(item.content || ''), ...placement(item, { width: 240, height: 160 }, documents.length + index) }
    if (type === 'folder') return { ...common, type: 'folder', isOpen: (item.config as Record<string, unknown> | undefined)?.open === true }
    if (type === 'event') return { ...common, type: 'event', eventDate: String(item.eventDate || '') || undefined }
    if (type === 'party') return { ...common, type: 'party', partyKind: item.partyKind === 'organization' ? 'organization' : 'person', organizationKind: item.organizationKind as OrganizationKind | undefined, aliases: Array.isArray(item.aliases) ? item.aliases.map(String) : [] }
    return { ...common, type: 'note', presentation:notePresentation(item.presentation) }
  })
  const derivedRelations: ExhibitRelation[] = [
    ...legacyRelations.filter(relation => relation.type === 'contains').map(relation => ({ id: String(relation.id), type: 'contains' as const, fromExhibitId: String(relation.fromWidgetId), toExhibitId: String(relation.toWidgetId), sortOrder: Number(relation.sortOrder || 0) })),
    ...legacyEvidence.flatMap(item => Array.isArray(item.supportingEvidenceIds) ? item.supportingEvidenceIds.map((id, index) => ({ id: `supports:${item.id}:${id}`, type: 'supports' as const, fromExhibitId: String(item.id), toExhibitId: String(id), sortOrder: index })) : []),
    ...legacyEvidence.flatMap(item => Array.isArray(item.relatedEvidenceIds) ? item.relatedEvidenceIds.map((id, index) => ({ id: `concerns:${item.id}:${id}`, type: 'concerns' as const, fromExhibitId: String(item.id), toExhibitId: String(id), sortOrder: index })) : []),
    ...legacyEvidence.flatMap(item => item.sourceDocumentId ? [{ id: `source:${item.id}`, type: 'source' as const, fromExhibitId: String(item.id), toExhibitId: String(item.sourceDocumentId), sourceRegionId: String(item.sourceRegionId || '') || undefined, sortOrder: 0 }] : []),
  ]
  const connections: Connection[] = (state.connections || []).map(connection => ({ ...connection, id: String(connection.id),
    fromExhibitId: String(connection.fromExhibitId || connection.fromEvidenceId), toExhibitId: String(connection.toExhibitId || connection.toEvidenceId) } as Connection))
  return { id: state.id, title: state.title, subtitle: state.subtitle, exhibits: [...documents, ...evidence], relations: derivedRelations, connections,
    views: [defaultTimelineView(state.timelineRange)], viewport: state.viewport, brief: { ...(state.brief || { body: '', concepts: [] }), checklist: state.brief?.checklist || [] }, goals: state.goals || [], report: state.report, revision: Number(state.revision || 0),
    updatedAt: state.updatedAt, levelStatus: state.levelStatus, sourceTemplateVersionId: state.sourceTemplateVersionId, editingAllowed: state.editingAllowed,
    newlyVisibleDocumentIds: state.newlyVisibleDocumentIds || [] }
}
