// @vitest-environment jsdom
/**
 * test/preview-grid.test.ts — shared launcher/manage preview helpers.
 *
 * xterm Terminal is stubbed; fetch is mocked. Covers the DRY core extracted in
 * Phase 4: formatting, the card factory, fit scaling, and the preview/list
 * fetch helpers (best-effort, no throw on failure).
 */

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

class FakeTerminal {
  cols = 80
  rows = 24
  open = vi.fn()
  reset = vi.fn()
  resize = vi.fn()
  dispose = vi.fn()
  write = vi.fn((_d: string, cb?: () => void) => cb?.())
}
vi.mock('@xterm/xterm', () => ({ Terminal: FakeTerminal }))

const mod = await import('../public/preview-grid.js')
const {
  el,
  relTime,
  statusText,
  sessionName,
  makePreviewCard,
  updatePreviewCard,
  fitThumb,
  fetchPreview,
  renderPreview,
  loadPreviewInto,
  fetchLiveSessions,
} = mod

function session(over: Partial<LiveSessionInfo> = {}): LiveSessionInfo {
  return {
    id: 'aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee',
    createdAt: Date.now() - 5000,
    clientCount: 0,
    status: 'idle',
    exited: false,
    cwd: '/work/proj',
    cols: 80,
    rows: 24,
    ...over,
  }
}

beforeEach(() => {
  vi.restoreAllMocks()
  vi.stubGlobal('requestAnimationFrame', (cb: FrameRequestCallback) => {
    cb(0)
    return 0
  })
})

describe('formatting helpers', () => {
  it('el builds an element with class + text', () => {
    const n = el('div', 'x', 'hi')
    expect(n.className).toBe('x')
    expect(n.textContent).toBe('hi')
  })

  it('relTime renders s/m/h/d buckets', () => {
    const now = Date.now()
    expect(relTime(now)).toMatch(/^\d+s$/)
    expect(relTime(now - 120_000)).toBe('2m')
    expect(relTime(now - 2 * 3600_000)).toBe('2h')
    expect(relTime(now - 3 * 86400_000)).toBe('3d')
  })

  it('statusText maps each status', () => {
    expect(statusText('working')).toContain('working')
    expect(statusText('waiting')).toContain('waiting')
    expect(statusText('idle')).toContain('idle')
    expect(statusText('unknown')).toBe('·')
  })

  it('sessionName uses last cwd segment, else short id', () => {
    expect(sessionName(session({ cwd: '/a/b/cool' }))).toBe('cool')
    expect(sessionName(session({ cwd: null }))).toBe('aaaaaaaa')
  })
})

describe('makePreviewCard / updatePreviewCard', () => {
  it('builds a card and fires onOpen when the thumb is clicked (button variant)', () => {
    const onOpen = vi.fn()
    const card = makePreviewCard(session(), { onOpen })
    card.thumb.click()
    expect(onOpen).toHaveBeenCalledWith(session().id)
    // The Open control is a <button> when no openHref is given.
    expect(card.el.querySelector('button.mg-open')).not.toBeNull()
  })

  it('uses an <a href> Open + extra actions when configured (manage variant)', () => {
    const extra = el('button', 'mg-kill', 'Kill')
    const card = makePreviewCard(session(), {
      onOpen: vi.fn(),
      openHref: (id) => `/?join=${id}`,
      extraActions: () => [extra],
    })
    const open = card.el.querySelector('a.mg-open') as HTMLAnchorElement | null
    expect(open?.getAttribute('href')).toBe(`/?join=${session().id}`)
    expect(card.el.contains(extra)).toBe(true)
  })

  it('updatePreviewCard refreshes status + watch in place', () => {
    const card = makePreviewCard(session(), { onOpen: vi.fn() })
    updatePreviewCard(card, session({ status: 'working', clientCount: 3 }))
    expect(card.status.textContent).toContain('working')
    expect(card.watch.textContent).toBe('👁 3')
    expect(card.watch.className).toContain('live')
  })
})

describe('fitThumb', () => {
  it('no-ops when the inner element has no measured size', () => {
    const card = makePreviewCard(session(), { onOpen: vi.fn() })
    // jsdom offsetWidth/Height are 0 → no transform applied.
    fitThumb(card, 320, 200)
    expect(card.inner.style.transform).toBe('')
  })

  it('scales and clamps height when inner has a size', () => {
    const card = makePreviewCard(session(), { onOpen: vi.fn() })
    Object.defineProperty(card.inner, 'offsetWidth', { value: 640, configurable: true })
    Object.defineProperty(card.inner, 'offsetHeight', { value: 400, configurable: true })
    fitThumb(card, 320, 200)
    expect(card.inner.style.transform).toBe('scale(0.5)')
    expect(card.thumb.style.height).toBe('200px')
  })
})

describe('fetch helpers', () => {
  it('fetchPreview returns parsed JSON on 200', async () => {
    vi.stubGlobal('fetch', vi.fn(async () => ({ ok: true, json: async () => ({ id: 'x', cols: 80, rows: 24, data: 'hi' }) })))
    expect(await fetchPreview('x')).toMatchObject({ id: 'x', data: 'hi' })
  })

  it('fetchPreview returns null on non-ok / throw', async () => {
    vi.stubGlobal('fetch', vi.fn(async () => ({ ok: false, json: async () => ({}) })))
    expect(await fetchPreview('x')).toBeNull()
    vi.stubGlobal('fetch', vi.fn(async () => { throw new Error('net') }))
    expect(await fetchPreview('x')).toBeNull()
  })

  it('renderPreview writes the cleared tail and resizes when dims differ', () => {
    const card = makePreviewCard(session(), { onOpen: vi.fn() })
    renderPreview(card, { id: 'x', cols: 100, rows: 30, data: 'screen' }, 320, 200)
    expect(card.term.resize).toHaveBeenCalledWith(100, 30)
    expect(card.term.write).toHaveBeenCalled()
  })

  it('loadPreviewInto fetches then renders (no throw on failure)', async () => {
    vi.stubGlobal('fetch', vi.fn(async () => ({ ok: true, json: async () => ({ id: 'x', cols: 80, rows: 24, data: 'd' }) })))
    const card = makePreviewCard(session(), { onOpen: vi.fn() })
    await expect(loadPreviewInto('x', card, 320, 200)).resolves.toBeUndefined()
    expect(card.term.write).toHaveBeenCalled()
  })

  it('fetchLiveSessions returns [] on failure and array on success', async () => {
    vi.stubGlobal('fetch', vi.fn(async () => ({ json: async () => [session()] })))
    expect(await fetchLiveSessions()).toHaveLength(1)
    vi.stubGlobal('fetch', vi.fn(async () => { throw new Error('x') }))
    expect(await fetchLiveSessions()).toEqual([])
  })
})
