// @vitest-environment jsdom
/**
 * test/grid-layout.test.ts — split-grid layout logic + toolbar toggle.
 *
 * Covers the pure layout math (capacity, visible set, isVisible), the desktop
 * matchMedia gate, localStorage persistence (incl. the narrow-screen force-single
 * rule), and the segmented toggle control (pressed state, click → setLayout,
 * hide-when-too-narrow, and the auto-fallback-to-single on window narrow).
 */

import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import {
  type GridLayout,
  GRID_LAYOUTS,
  GRID_MIN_WIDTH,
  layoutCapacity,
  visibleIndices,
  isVisibleIndex,
  gridAllowed,
  loadGridLayout,
  saveGridLayout,
  mountGridToggle,
  matchFocusCycleKey,
  layoutTracks,
  defaultSplit,
  adjustSplit,
  tracksToTemplate,
  splitForLayout,
  loadGridSplits,
  saveGridSplits,
} from '../public/grid-layout.js'

const KEY = 'web-terminal:grid-layout'

/** Install a matchMedia stub whose `(min-width: N)` matches iff width >= N.
 *  Returns a fire() to simulate a viewport resize crossing the threshold. */
function stubMatchMedia(width: number): { setWidth: (w: number) => void } {
  let current = width
  const lists: Array<{ q: string; list: { matches: boolean }; cbs: Array<() => void> }> = []
  const parse = (q: string): number => Number(/min-width:\s*(\d+)px/.exec(q)?.[1] ?? '0')
  window.matchMedia = ((q: string) => {
    const cbs: Array<() => void> = []
    const list = {
      get matches() {
        return current >= parse(q)
      },
      media: q,
      addEventListener: (_t: string, cb: () => void) => cbs.push(cb),
      removeEventListener: (_t: string, cb: () => void) => {
        const i = cbs.indexOf(cb)
        if (i >= 0) cbs.splice(i, 1)
      },
      addListener: (cb: () => void) => cbs.push(cb),
      removeListener: () => {},
      dispatchEvent: () => true,
      onchange: null,
    }
    lists.push({ q, list, cbs })
    return list
  }) as unknown as typeof window.matchMedia
  return {
    setWidth: (w: number) => {
      current = w
      for (const { cbs } of lists) for (const cb of cbs) cb()
    },
  }
}

describe('grid-layout: pure logic', () => {
  it('layoutCapacity maps each layout to its pane count', () => {
    expect(layoutCapacity('single')).toBe(1)
    expect(layoutCapacity('split-2')).toBe(2)
    expect(layoutCapacity('row-3')).toBe(3)
    expect(layoutCapacity('grid-4')).toBe(4)
    expect(layoutCapacity('grid-6')).toBe(6)
  })

  it('GRID_LAYOUTS is ordered by capacity', () => {
    expect(GRID_LAYOUTS).toEqual(['single', 'split-2', 'row-3', 'grid-4', 'grid-6'])
  })

  it('visibleIndices honors the higher-capacity layouts', () => {
    expect(visibleIndices(9, 'row-3')).toEqual([0, 1, 2])
    expect(visibleIndices(9, 'grid-6')).toEqual([0, 1, 2, 3, 4, 5])
    expect(visibleIndices(4, 'grid-6')).toEqual([0, 1, 2, 3])
  })

  it('matchFocusCycleKey recognizes Ctrl+` (fwd) and Ctrl+Shift+` (rev), else null', () => {
    const k = (init: KeyboardEventInit): KeyboardEvent => new KeyboardEvent('keydown', init)
    expect(matchFocusCycleKey(k({ ctrlKey: true, key: '`' }))).toBe(1)
    expect(matchFocusCycleKey(k({ ctrlKey: true, shiftKey: true, key: '`' }))).toBe(-1)
    expect(matchFocusCycleKey(k({ ctrlKey: true, code: 'Backquote' }))).toBe(1)
    expect(matchFocusCycleKey(k({ key: '`' }))).toBeNull() // no ctrl
    expect(matchFocusCycleKey(k({ ctrlKey: true, metaKey: true, key: '`' }))).toBeNull() // meta excluded
    expect(matchFocusCycleKey(k({ ctrlKey: true, altKey: true, key: '`' }))).toBeNull() // alt excluded
    expect(matchFocusCycleKey(k({ ctrlKey: true, key: 'a' }))).toBeNull() // wrong key
  })

  it('visibleIndices returns the first min(tabCount, capacity) indices', () => {
    expect(visibleIndices(0, 'grid-4')).toEqual([])
    expect(visibleIndices(3, 'grid-4')).toEqual([0, 1, 2])
    expect(visibleIndices(9, 'grid-4')).toEqual([0, 1, 2, 3])
    expect(visibleIndices(9, 'split-2')).toEqual([0, 1])
    expect(visibleIndices(9, 'single')).toEqual([0])
  })

  it('isVisibleIndex: single mode shows only the active pane', () => {
    expect(isVisibleIndex(2, 2, 5, 'single')).toBe(true)
    expect(isVisibleIndex(0, 2, 5, 'single')).toBe(false)
  })

  it('isVisibleIndex: grid shows the first-N regardless of which is active', () => {
    expect(isVisibleIndex(0, 3, 9, 'grid-4')).toBe(true)
    expect(isVisibleIndex(3, 3, 9, 'grid-4')).toBe(true)
    expect(isVisibleIndex(4, 3, 9, 'grid-4')).toBe(false) // off-board
    expect(isVisibleIndex(2, 0, 9, 'split-2')).toBe(false)
  })
})

describe('grid-layout: resizable splitters (v3)', () => {
  beforeEach(() => localStorage.clear())

  it('layoutTracks gives each layout its column/row counts', () => {
    expect(layoutTracks('single')).toEqual({ cols: 1, rows: 1 })
    expect(layoutTracks('split-2')).toEqual({ cols: 2, rows: 1 })
    expect(layoutTracks('row-3')).toEqual({ cols: 3, rows: 1 })
    expect(layoutTracks('grid-4')).toEqual({ cols: 2, rows: 2 })
    expect(layoutTracks('grid-6')).toEqual({ cols: 3, rows: 2 })
  })

  it('defaultSplit is equal fractions matching the track counts', () => {
    expect(defaultSplit('grid-4')).toEqual({ cols: [1, 1], rows: [1, 1] })
    expect(defaultSplit('grid-6')).toEqual({ cols: [1, 1, 1], rows: [1, 1] })
  })

  it('adjustSplit trades the delta between adjacent tracks, conserving the total', () => {
    expect(adjustSplit([1, 1], 0, 0.5)).toEqual([1.5, 0.5])
    expect(adjustSplit([1, 1, 1], 1, 0.4)).toEqual([1, 1.4, 0.6])
  })

  it('adjustSplit clamps both tracks to the minimum (no collapse)', () => {
    const out = adjustSplit([1, 1], 0, 5, 0.3) // huge delta
    expect(out[1]).toBeCloseTo(0.3)
    expect(out[0]! + out[1]!).toBeCloseTo(2) // total preserved
    expect(Math.min(...out)).toBeGreaterThanOrEqual(0.3)
  })

  it('adjustSplit ignores an out-of-range gutter and never mutates the input', () => {
    const input = [1, 1]
    expect(adjustSplit(input, 1, 0.5)).toEqual([1, 1]) // gutter 1 has no right neighbor
    expect(input).toEqual([1, 1]) // untouched
  })

  it('tracksToTemplate renders an fr template', () => {
    expect(tracksToTemplate([1, 1.4])).toBe('1fr 1.4fr')
  })

  it('splitForLayout returns the stored split only when its shape matches', () => {
    const good = { 'grid-4': { cols: [1.3, 0.7], rows: [1, 1] } }
    expect(splitForLayout(good, 'grid-4')).toEqual({ cols: [1.3, 0.7], rows: [1, 1] })
    // wrong column count for grid-4 → falls back to equal default
    expect(splitForLayout({ 'grid-4': { cols: [1, 1, 1], rows: [1, 1] } }, 'grid-4')).toEqual(
      defaultSplit('grid-4'),
    )
    expect(splitForLayout({}, 'split-2')).toEqual(defaultSplit('split-2'))
  })

  it('splitForLayout falls back to default for a null stored entry (no throw)', () => {
    // {"grid-4":null} is valid JSON a corrupt/tampered store could hold.
    expect(splitForLayout({ 'grid-4': null } as never, 'grid-4')).toEqual(defaultSplit('grid-4'))
  })

  it('saveGridSplits + loadGridSplits round-trip', () => {
    saveGridSplits({ 'grid-4': { cols: [1.5, 0.5], rows: [1, 1] } })
    expect(loadGridSplits()).toEqual({ 'grid-4': { cols: [1.5, 0.5], rows: [1, 1] } })
  })

  it('loadGridSplits tolerates garbage', () => {
    localStorage.setItem('web-terminal:grid-splits', 'not json')
    expect(loadGridSplits()).toEqual({})
  })
})

describe('grid-layout: gate + persistence', () => {
  beforeEach(() => {
    localStorage.clear()
  })
  afterEach(() => {
    vi.restoreAllMocks()
    // @ts-expect-error — reset the stub between tests
    delete window.matchMedia
  })

  it('gridAllowed is true at/above GRID_MIN_WIDTH and false below', () => {
    stubMatchMedia(GRID_MIN_WIDTH)
    expect(gridAllowed()).toBe(true)
    stubMatchMedia(GRID_MIN_WIDTH - 1)
    expect(gridAllowed()).toBe(false)
  })

  it('saveGridLayout + loadGridLayout round-trips on a wide screen', () => {
    stubMatchMedia(1440)
    saveGridLayout('grid-4')
    expect(localStorage.getItem(KEY)).toBe('grid-4')
    expect(loadGridLayout()).toBe('grid-4')
  })

  it('loadGridLayout forces single on a narrow screen (no grid on a phone)', () => {
    stubMatchMedia(1440)
    saveGridLayout('grid-4')
    stubMatchMedia(800) // window shrank / opened on a small device
    expect(loadGridLayout()).toBe('single')
  })

  it('loadGridLayout ignores a garbage stored value', () => {
    stubMatchMedia(1440)
    localStorage.setItem(KEY, 'not-a-layout')
    expect(loadGridLayout()).toBe('single')
  })
})

describe('grid-layout: mountGridToggle', () => {
  let toolbar: HTMLElement
  beforeEach(() => {
    localStorage.clear()
    toolbar = document.createElement('div')
    document.body.appendChild(toolbar)
  })
  afterEach(() => {
    toolbar.remove()
    // @ts-expect-error — reset the stub
    delete window.matchMedia
  })

  it('renders one button per layout with the current one pressed', () => {
    stubMatchMedia(1440)
    let layout: GridLayout = 'split-2'
    mountGridToggle(toolbar, { getLayout: () => layout, setLayout: (l) => (layout = l) })
    const btns = toolbar.querySelectorAll<HTMLButtonElement>('.grid-toggle-btn')
    expect(btns).toHaveLength(GRID_LAYOUTS.length)
    const pressed = [...btns].filter((b) => b.getAttribute('aria-pressed') === 'true')
    expect(pressed).toHaveLength(1)
    expect(pressed[0]!.dataset.layout).toBe('split-2')
  })

  it('clicking a button calls setLayout with that layout', () => {
    stubMatchMedia(1440)
    const setLayout = vi.fn()
    mountGridToggle(toolbar, { getLayout: () => 'single', setLayout })
    const grid = toolbar.querySelector<HTMLButtonElement>('.grid-toggle-btn[data-layout="grid-4"]')!
    grid.click()
    expect(setLayout).toHaveBeenCalledWith('grid-4')
  })

  it('is hidden below the desktop threshold', () => {
    stubMatchMedia(800)
    mountGridToggle(toolbar, { getLayout: () => 'single', setLayout: vi.fn() })
    const seg = toolbar.querySelector<HTMLElement>('.grid-toggle')!
    expect(seg.style.display).toBe('none')
  })

  it('forces single + hides itself when the window narrows past the threshold', () => {
    const mm = stubMatchMedia(1440)
    let layout: GridLayout = 'grid-4'
    const setLayout = vi.fn((l: GridLayout) => {
      layout = l
    })
    mountGridToggle(toolbar, { getLayout: () => layout, setLayout })
    const seg = toolbar.querySelector<HTMLElement>('.grid-toggle')!
    expect(seg.style.display).toBe('inline-flex')
    mm.setWidth(700) // simulate a resize below the threshold
    expect(setLayout).toHaveBeenCalledWith('single')
    expect(seg.style.display).toBe('none')
  })
})
