/**
 * public/timeline.ts — Activity timeline panel (N-timeline-ui, A4).
 *
 * Renders a live-polling panel of server-derived timeline events for a session.
 * Fetches GET /live-sessions/:id/events and shows rows: "HH:MM · icon · label".
 *
 * All event strings are set via textContent only (SEC-H6 — labels may contain
 * tool-derived content such as file paths). Zero innerHTML with external data.
 *
 * Owned by: N-timeline-ui (W1, public/timeline.ts)
 * Depends on: T-types (TimelineEvent, TimelineClass)
 */

import type { TimelineEvent, TimelineClass } from '../src/types.js'
import { el } from './preview-grid.js'

/* ─── Constants ─────────────────────────────────────────────────────────── */

const DEFAULT_REFRESH_MS = 5_000
const DEFAULT_MAX_EVENTS = 50
const LABEL_MAX_LEN = 500
const TOOL_NAME_MAX_LEN = 200

const VALID_CLASSES: ReadonlySet<string> = new Set<TimelineClass>([
  'tool',
  'waiting',
  'done',
  'stuck',
  'user',
])

/* ─── Public types ───────────────────────────────────────────────────────── */

/** Handle returned by mountTimeline; call dispose() to stop polling. */
export interface TimelineHandle {
  dispose(): void
}

/** Options for mountTimeline. */
export interface TimelineOpts {
  /** Polling interval in ms (default 5 000). */
  refreshMs?: number
  /** Maximum number of events to display (default 50). */
  maxEvents?: number
}

/* ─── normalizeTimelineEvent ─────────────────────────────────────────────── */

/**
 * Safely narrow an unknown value to TimelineEvent, returning null on failure.
 * Never throws. Validates all fields defensively (SEC-H6).
 */
export function normalizeTimelineEvent(raw: unknown): TimelineEvent | null {
  if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) return null

  const r = raw as Record<string, unknown>

  // Validate `at` — must be a finite number
  if (typeof r['at'] !== 'number' || !Number.isFinite(r['at'])) return null

  // Validate `class` — must be a known TimelineClass string
  if (!isValidClass(r['class'])) return null

  // Validate `label` — must be a string
  if (typeof r['label'] !== 'string') return null

  const event: TimelineEvent = {
    at: r['at'],
    class: r['class'],
    label: r['label'].slice(0, LABEL_MAX_LEN),
  }

  // Optional toolName
  if (typeof r['toolName'] === 'string') {
    event.toolName = r['toolName'].slice(0, TOOL_NAME_MAX_LEN)
  }

  return event
}

function isValidClass(v: unknown): v is TimelineClass {
  return typeof v === 'string' && VALID_CLASSES.has(v)
}

/* ─── timelineIcon ───────────────────────────────────────────────────────── */

/** Map a TimelineClass to a display icon string. */
export function timelineIcon(cls: TimelineClass): string {
  switch (cls) {
    case 'tool':    return '🔧'
    case 'waiting': return '⏳'
    case 'done':    return '✓'
    case 'stuck':   return '⚠'
    case 'user':    return '💬'
  }
}

/* ─── Time formatting ────────────────────────────────────────────────────── */

/** Format a unix-ms timestamp as HH:MM (24-hour wall-clock). */
function formatHHMM(ms: number): string {
  const d = new Date(ms)
  const h = d.getHours().toString().padStart(2, '0')
  const m = d.getMinutes().toString().padStart(2, '0')
  return `${h}:${m}`
}

/* ─── renderTimelineEvent ────────────────────────────────────────────────── */

/**
 * Render one timeline event as a DOM row: "HH:MM · icon · label".
 *
 * All strings set via textContent (SEC-H6). Returns a <div class="tl-row">
 * with three child spans: tl-time, tl-icon-<class>, tl-label.
 */
export function renderTimelineEvent(ev: TimelineEvent): HTMLElement {
  const row = el('div', 'tl-row')
  row.append(
    el('span', 'tl-time', formatHHMM(ev.at)),
    el('span', `tl-icon tl-icon-${ev.class}`, timelineIcon(ev.class)),
    el('span', 'tl-label', ev.label), // textContent only — SEC-H6
  )
  return row
}

/* ─── fetchTimeline ──────────────────────────────────────────────────────── */

/**
 * Fetch the activity timeline for `id` from GET /live-sessions/:id/events.
 * Returns [] on any error (network failure, non-ok response, bad shape).
 */
export async function fetchTimeline(id: string): Promise<TimelineEvent[]> {
  try {
    const res = await fetch(`/live-sessions/${encodeURIComponent(id)}/events`)
    if (!res.ok) return []
    const data: unknown = await res.json()
    if (!Array.isArray(data)) return []
    return data
      .map((item: unknown) => normalizeTimelineEvent(item))
      .filter((ev): ev is TimelineEvent => ev !== null)
  } catch {
    return []
  }
}

/* ─── mountTimeline ──────────────────────────────────────────────────────── */

/**
 * Mount a live-polling activity timeline panel into `container`.
 *
 * - Fetches GET /live-sessions/:id/events immediately on mount.
 * - Re-polls every `refreshMs` ms (default 5 s) while the container is
 *   in the viewport (IntersectionObserver when available, always-on fallback).
 * - Renders events newest-first, capped at `maxEvents`.
 * - Shows an empty-state message when there are no events.
 * - Call dispose() to stop polling and disconnect the observer.
 *
 * Security: event labels rendered via textContent only (SEC-H6).
 */
export function mountTimeline(
  container: HTMLElement,
  id: string,
  opts?: TimelineOpts,
): TimelineHandle {
  const refreshMs = opts?.refreshMs ?? DEFAULT_REFRESH_MS
  const maxEvents = opts?.maxEvents ?? DEFAULT_MAX_EVENTS

  let visible = true
  let intervalId: ReturnType<typeof setInterval> | null = null
  let observer: IntersectionObserver | null = null
  let disposed = false

  /** Clear the container and re-render events newest-first. */
  function render(events: TimelineEvent[]): void {
    while (container.firstChild) container.removeChild(container.firstChild)

    const capped = events.slice(0, maxEvents)
    // Reverse so newest is first (server returns oldest-first)
    const newestFirst = [...capped].reverse()

    if (newestFirst.length === 0) {
      container.append(el('div', 'tl-empty', 'No activity yet'))
      return
    }

    for (const ev of newestFirst) {
      container.append(renderTimelineEvent(ev))
    }
  }

  async function poll(): Promise<void> {
    if (!visible || disposed) return
    const events = await fetchTimeline(id)
    if (!disposed) render(events)
  }

  // Initial fetch
  void poll()

  // Start interval polling
  intervalId = setInterval(() => { void poll() }, refreshMs)

  // Pause/resume based on viewport visibility when IntersectionObserver is available
  if (typeof IntersectionObserver !== 'undefined') {
    observer = new IntersectionObserver((entries) => {
      const entry = entries[0]
      visible = entry?.isIntersecting ?? true
    })
    observer.observe(container)
  }

  return {
    dispose(): void {
      if (disposed) return
      disposed = true
      if (intervalId !== null) {
        clearInterval(intervalId)
        intervalId = null
      }
      if (observer !== null) {
        observer.disconnect()
        observer = null
      }
    },
  }
}
