// @vitest-environment jsdom
/**
 * test/timeline.test.ts — N-timeline-ui: activity timeline panel
 *
 * Covers: normalizeTimelineEvent (defensive), timelineIcon (mapping),
 * renderTimelineEvent (textContent/XSS), fetchTimeline (error→[]),
 * mountTimeline (polling start/stop, dispose clears interval, empty state,
 * newest-first, maxEvents cap).
 *
 * Security: SEC-H6 (label/toolName rendered via textContent, not innerHTML).
 */

import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import type { TimelineEvent, TimelineClass } from '../src/types.js'

// Stub @xterm/xterm (pulled in transitively via preview-grid.ts import).
vi.mock('@xterm/xterm', () => ({
  Terminal: class FakeTerm {
    cols = 80
    rows = 24
    open = vi.fn()
    reset = vi.fn()
    resize = vi.fn()
    dispose = vi.fn()
    write = vi.fn((_d: string, cb?: () => void) => cb?.())
  },
}))

const {
  normalizeTimelineEvent,
  timelineIcon,
  renderTimelineEvent,
  fetchTimeline,
  mountTimeline,
} = await import('../public/timeline.js')

// ─── helpers ────────────────────────────────────────────────────────────────

function makeEvent(over: Partial<TimelineEvent> = {}): TimelineEvent {
  return {
    at: Date.now(),
    class: 'tool',
    label: 'ran Bash',
    ...over,
  }
}

function makeRaw(over: Record<string, unknown> = {}): unknown {
  return {
    at: Date.now(),
    class: 'tool',
    label: 'ran Bash',
    ...over,
  }
}

// ─── normalizeTimelineEvent ──────────────────────────────────────────────────

describe('normalizeTimelineEvent', () => {
  it('returns null for null input', () => {
    expect(normalizeTimelineEvent(null)).toBeNull()
  })

  it('returns null for undefined input', () => {
    expect(normalizeTimelineEvent(undefined)).toBeNull()
  })

  it('returns null for non-object (string)', () => {
    expect(normalizeTimelineEvent('hello')).toBeNull()
  })

  it('returns null for non-object (number)', () => {
    expect(normalizeTimelineEvent(42)).toBeNull()
  })

  it('returns null for array input', () => {
    expect(normalizeTimelineEvent([1, 2, 3])).toBeNull()
  })

  it('returns null when `at` is missing', () => {
    expect(normalizeTimelineEvent({ class: 'tool', label: 'hi' })).toBeNull()
  })

  it('returns null when `at` is a string', () => {
    expect(normalizeTimelineEvent(makeRaw({ at: '2024-01-01' }))).toBeNull()
  })

  it('returns null when `at` is NaN', () => {
    expect(normalizeTimelineEvent(makeRaw({ at: NaN }))).toBeNull()
  })

  it('returns null when `at` is Infinity', () => {
    expect(normalizeTimelineEvent(makeRaw({ at: Infinity }))).toBeNull()
  })

  it('returns null when `label` is missing', () => {
    expect(normalizeTimelineEvent({ at: Date.now(), class: 'tool' })).toBeNull()
  })

  it('returns null when `label` is a number', () => {
    expect(normalizeTimelineEvent(makeRaw({ label: 42 }))).toBeNull()
  })

  it('returns null when `class` is missing', () => {
    expect(normalizeTimelineEvent({ at: Date.now(), label: 'hi' })).toBeNull()
  })

  it('returns null when `class` is an unknown string', () => {
    expect(normalizeTimelineEvent(makeRaw({ class: 'unknown-garbage' }))).toBeNull()
  })

  it('returns null when `class` is not a string', () => {
    expect(normalizeTimelineEvent(makeRaw({ class: 99 }))).toBeNull()
  })

  it('accepts all valid TimelineClass values', () => {
    const classes: TimelineClass[] = ['tool', 'waiting', 'done', 'stuck', 'user']
    for (const cls of classes) {
      const result = normalizeTimelineEvent(makeRaw({ class: cls }))
      expect(result).not.toBeNull()
      expect(result?.class).toBe(cls)
    }
  })

  it('returns a valid TimelineEvent for a well-formed input', () => {
    const now = Date.now()
    const result = normalizeTimelineEvent({ at: now, class: 'done', label: 'session ended' })
    expect(result).toEqual({ at: now, class: 'done', label: 'session ended' })
  })

  it('includes toolName when it is a string', () => {
    const result = normalizeTimelineEvent(makeRaw({ toolName: 'Bash' }))
    expect(result?.toolName).toBe('Bash')
  })

  it('omits toolName when it is not a string', () => {
    const result = normalizeTimelineEvent(makeRaw({ toolName: 123 }))
    expect(result?.toolName).toBeUndefined()
  })

  it('truncates label at 500 characters', () => {
    const longLabel = 'x'.repeat(600)
    const result = normalizeTimelineEvent(makeRaw({ label: longLabel }))
    expect(result?.label.length).toBeLessThanOrEqual(500)
  })

  it('truncates toolName at 200 characters', () => {
    const longName = 'y'.repeat(300)
    const result = normalizeTimelineEvent(makeRaw({ toolName: longName }))
    expect(result?.toolName?.length).toBeLessThanOrEqual(200)
  })

  it('does not throw on arbitrary garbage inputs', () => {
    const garbage = [
      {},
      [],
      null,
      undefined,
      false,
      0,
      '',
      { at: null, class: {}, label: [] },
      { at: 1, class: 'tool', label: 'ok', toolName: {} },
    ]
    for (const g of garbage) {
      expect(() => normalizeTimelineEvent(g)).not.toThrow()
    }
  })
})

// ─── timelineIcon ────────────────────────────────────────────────────────────

describe('timelineIcon', () => {
  it('returns a non-empty string for every TimelineClass', () => {
    const classes: TimelineClass[] = ['tool', 'waiting', 'done', 'stuck', 'user']
    for (const cls of classes) {
      const icon = timelineIcon(cls)
      expect(typeof icon).toBe('string')
      expect(icon.length).toBeGreaterThan(0)
    }
  })

  it('maps tool to a tool-related icon', () => {
    expect(timelineIcon('tool')).toBeTruthy()
  })

  it('maps waiting to a waiting icon', () => {
    expect(timelineIcon('waiting')).toBeTruthy()
  })

  it('maps done to a done/check icon', () => {
    const icon = timelineIcon('done')
    expect(typeof icon).toBe('string')
  })

  it('maps stuck to a warning icon containing ⚠', () => {
    const icon = timelineIcon('stuck')
    expect(icon).toContain('⚠')
  })

  it('maps user to a user icon', () => {
    expect(timelineIcon('user')).toBeTruthy()
  })

  it('returns distinct icons for each class', () => {
    const classes: TimelineClass[] = ['tool', 'waiting', 'done', 'stuck', 'user']
    const icons = classes.map(timelineIcon)
    const unique = new Set(icons)
    expect(unique.size).toBe(classes.length)
  })
})

// ─── renderTimelineEvent ─────────────────────────────────────────────────────

describe('renderTimelineEvent', () => {
  it('returns an HTMLElement', () => {
    const el = renderTimelineEvent(makeEvent())
    expect(el).toBeInstanceOf(HTMLElement)
  })

  it('does NOT use innerHTML (textContent only — SEC-H6)', () => {
    const ev = makeEvent({ label: '<script>alert(1)</script>' })
    const row = renderTimelineEvent(ev)
    // innerHTML of the row should not produce script elements
    const scripts = row.querySelectorAll('script')
    expect(scripts.length).toBe(0)
    // The raw string should appear as text somewhere in the tree
    expect(row.textContent).toContain('<script>alert(1)</script>')
  })

  it('renders the label text as textContent', () => {
    const ev = makeEvent({ label: 'edited 3 files' })
    const row = renderTimelineEvent(ev)
    expect(row.textContent).toContain('edited 3 files')
  })

  it('renders the icon for the event class', () => {
    const ev = makeEvent({ class: 'stuck' })
    const row = renderTimelineEvent(ev)
    expect(row.textContent).toContain('⚠')
  })

  it('renders a time portion in the row', () => {
    const ev = makeEvent({ at: new Date('2024-06-15T14:32:00').getTime() })
    const row = renderTimelineEvent(ev)
    // Should contain digits forming a time pattern (HH:MM)
    expect(row.textContent).toMatch(/\d{1,2}:\d{2}/)
  })

  it('renders HTML-special characters in label as plain text', () => {
    const ev = makeEvent({ label: '<b>bold</b> & "quotes"' })
    const row = renderTimelineEvent(ev)
    expect(row.querySelectorAll('b').length).toBe(0)
    expect(row.textContent).toContain('<b>bold</b>')
  })

  it('renders ANSI escape sequences in label as plain text', () => {
    const ev = makeEvent({ label: '\x1b[31mred\x1b[0m text' })
    const row = renderTimelineEvent(ev)
    expect(row.textContent).toContain('\x1b[31mred\x1b[0m text')
  })

  it('renders all five classes without throwing', () => {
    const classes: TimelineClass[] = ['tool', 'waiting', 'done', 'stuck', 'user']
    for (const cls of classes) {
      expect(() => renderTimelineEvent(makeEvent({ class: cls }))).not.toThrow()
    }
  })
})

// ─── fetchTimeline ───────────────────────────────────────────────────────────

describe('fetchTimeline', () => {
  beforeEach(() => {
    vi.restoreAllMocks()
  })

  it('returns [] on network error', async () => {
    vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('net fail')))
    const result = await fetchTimeline('sess-1')
    expect(result).toEqual([])
  })

  it('returns [] when response is not ok', async () => {
    vi.stubGlobal(
      'fetch',
      vi.fn().mockResolvedValue({ ok: false, json: async () => [] }),
    )
    const result = await fetchTimeline('sess-1')
    expect(result).toEqual([])
  })

  it('returns [] when response JSON is not an array', async () => {
    vi.stubGlobal(
      'fetch',
      vi.fn().mockResolvedValue({ ok: true, json: async () => ({ events: [] }) }),
    )
    const result = await fetchTimeline('sess-1')
    expect(result).toEqual([])
  })

  it('returns [] when json() rejects', async () => {
    vi.stubGlobal(
      'fetch',
      vi.fn().mockResolvedValue({ ok: true, json: async () => { throw new Error('bad json') } }),
    )
    const result = await fetchTimeline('sess-1')
    expect(result).toEqual([])
  })

  it('fetches from the correct URL', async () => {
    const mockFetch = vi.fn().mockResolvedValue({ ok: true, json: async () => [] })
    vi.stubGlobal('fetch', mockFetch)
    await fetchTimeline('my-session-id')
    expect(mockFetch).toHaveBeenCalledWith(expect.stringContaining('my-session-id'))
    expect(mockFetch).toHaveBeenCalledWith(expect.stringContaining('/events'))
  })

  it('normalizes and returns valid events', async () => {
    const now = Date.now()
    const rawEvents = [
      { at: now, class: 'tool', label: 'ran Bash', toolName: 'Bash' },
      { at: now, class: 'done', label: 'session ended' },
    ]
    vi.stubGlobal(
      'fetch',
      vi.fn().mockResolvedValue({ ok: true, json: async () => rawEvents }),
    )
    const result = await fetchTimeline('sess-2')
    expect(result.length).toBe(2)
    expect(result[0]?.class).toBe('tool')
    expect(result[1]?.class).toBe('done')
  })

  it('filters out invalid events from the response array', async () => {
    const now = Date.now()
    const rawEvents = [
      { at: now, class: 'tool', label: 'ok' },
      { at: 'bad', class: 'tool', label: 'ok' }, // invalid at
      null,
      { at: now, class: 'invalid-class', label: 'ok' }, // invalid class
    ]
    vi.stubGlobal(
      'fetch',
      vi.fn().mockResolvedValue({ ok: true, json: async () => rawEvents }),
    )
    const result = await fetchTimeline('sess-3')
    expect(result.length).toBe(1)
    expect(result[0]?.label).toBe('ok')
  })
})

// ─── mountTimeline ───────────────────────────────────────────────────────────

describe('mountTimeline', () => {
  beforeEach(() => {
    vi.useFakeTimers()
    vi.restoreAllMocks()
  })

  afterEach(() => {
    vi.useRealTimers()
  })

  function makeContainer(): HTMLDivElement {
    return document.createElement('div')
  }

  /**
   * Flush the microtask queue (Promise chain) without advancing fake timers.
   * Needed to let `void poll()` → `fetchTimeline()` → `render()` complete.
   * Four ticks cover: poll() entry → fetch call → await response → render().
   */
  async function flushPromises(): Promise<void> {
    await Promise.resolve()
    await Promise.resolve()
    await Promise.resolve()
    await Promise.resolve()
  }

  it('does an initial fetch on mount', async () => {
    const mockFetch = vi.fn().mockResolvedValue({ ok: true, json: async () => [] })
    vi.stubGlobal('fetch', mockFetch)
    const container = makeContainer()
    const handle = mountTimeline(container, 'sess-1')
    await flushPromises()
    expect(mockFetch).toHaveBeenCalled()
    handle.dispose()
  })

  it('shows empty state when no events are returned', async () => {
    vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: true, json: async () => [] }))
    const container = makeContainer()
    const handle = mountTimeline(container, 'sess-empty')
    await flushPromises()
    expect(container.textContent).toContain('No activity')
    handle.dispose()
  })

  it('renders events newest-first (reverses server order)', async () => {
    const now = Date.now()
    const events = [
      { at: now - 2000, class: 'tool', label: 'first event' },
      { at: now - 1000, class: 'tool', label: 'second event' },
      { at: now, class: 'done', label: 'done event' },
    ]
    vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: true, json: async () => events }))
    const container = makeContainer()
    const handle = mountTimeline(container, 'sess-order')
    await flushPromises()
    const rows = container.querySelectorAll('.tl-row')
    // Newest first: 'done event' at index 0, 'first event' at last
    expect(rows[0]?.textContent).toContain('done event')
    expect(rows[rows.length - 1]?.textContent).toContain('first event')
    handle.dispose()
  })

  it('caps rendered events at maxEvents', async () => {
    const now = Date.now()
    const events = Array.from({ length: 10 }, (_, i) => ({
      at: now + i,
      class: 'tool',
      label: `event ${i}`,
    }))
    vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: true, json: async () => events }))
    const container = makeContainer()
    const handle = mountTimeline(container, 'sess-cap', { maxEvents: 3 })
    await flushPromises()
    const rows = container.querySelectorAll('.tl-row')
    expect(rows.length).toBe(3)
    handle.dispose()
  })

  it('polls at the specified refreshMs interval', async () => {
    const mockFetch = vi.fn().mockResolvedValue({ ok: true, json: async () => [] })
    vi.stubGlobal('fetch', mockFetch)
    const container = makeContainer()
    const handle = mountTimeline(container, 'sess-poll', { refreshMs: 1000 })
    await flushPromises()
    const callsAfterMount = mockFetch.mock.calls.length

    // Advance by refreshMs → fire the interval once
    vi.advanceTimersByTime(1000)
    await flushPromises()
    expect(mockFetch.mock.calls.length).toBeGreaterThan(callsAfterMount)

    handle.dispose()
  })

  it('dispose stops polling (no more fetches after dispose)', async () => {
    const mockFetch = vi.fn().mockResolvedValue({ ok: true, json: async () => [] })
    vi.stubGlobal('fetch', mockFetch)
    const container = makeContainer()
    const handle = mountTimeline(container, 'sess-dispose', { refreshMs: 500 })
    await flushPromises()

    handle.dispose()
    const callsAfterDispose = mockFetch.mock.calls.length

    // Advance time — interval is cleared, no more fetches
    vi.advanceTimersByTime(2000)
    await flushPromises()
    expect(mockFetch.mock.calls.length).toBe(callsAfterDispose)
  })

  it('calling dispose twice does not throw', async () => {
    vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: true, json: async () => [] }))
    const container = makeContainer()
    const handle = mountTimeline(container, 'sess-double-dispose')
    await flushPromises()
    expect(() => {
      handle.dispose()
      handle.dispose()
    }).not.toThrow()
  })

  it('returns a handle with a dispose function', () => {
    vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: true, json: async () => [] }))
    const container = makeContainer()
    const handle = mountTimeline(container, 'sess-handle')
    expect(typeof handle.dispose).toBe('function')
    handle.dispose()
  })

  it('uses the label field rendered as textContent (SEC-H6)', async () => {
    const now = Date.now()
    vi.stubGlobal(
      'fetch',
      vi.fn().mockResolvedValue({
        ok: true,
        json: async () => [{ at: now, class: 'tool', label: '<script>xss</script>' }],
      }),
    )
    const container = makeContainer()
    const handle = mountTimeline(container, 'sess-xss')
    await flushPromises()
    expect(container.querySelectorAll('script').length).toBe(0)
    expect(container.textContent).toContain('<script>xss</script>')
    handle.dispose()
  })

  it('clears and re-renders on each poll cycle', async () => {
    let callCount = 0
    vi.stubGlobal(
      'fetch',
      vi.fn().mockImplementation(async () => {
        callCount++
        const label = `event-cycle-${callCount}`
        return { ok: true, json: async () => [{ at: Date.now(), class: 'tool', label }] }
      }),
    )
    const container = makeContainer()
    const handle = mountTimeline(container, 'sess-refresh', { refreshMs: 500 })
    await flushPromises()
    expect(container.textContent).toContain('event-cycle-1')

    vi.advanceTimersByTime(500)
    await flushPromises()
    // After re-render, shows latest cycle data
    expect(container.textContent).toContain('event-cycle-2')
    // Only shows one set of rows (cleared before re-render)
    expect(container.querySelectorAll('.tl-row').length).toBe(1)

    handle.dispose()
  })
})
