// @vitest-environment jsdom
/**
 * test/quick-reply.test.ts — N-quickreply: quick-reply chips + saved-prompt palette.
 *
 * Feature: A3 (Walk-away Workbench)
 * Owns:    public/quick-reply.ts
 *
 * Acceptance criteria (AC-A3.x):
 *   AC-A3.1  Built-in chips present and send correct bytes (including \r).
 *   AC-A3.2  User palette CRUD is immutable; round-trip persists via localStorage.
 *   AC-A3.3  Chip click sends via the onSend path (same text + optional \r).
 *   AC-A3.4  Labels with special characters rendered as inert text (textContent),
 *            never injected as HTML (SEC-L3).
 *
 * Coverage target: ≥80% of public/quick-reply.ts
 */

import { describe, it, expect, vi, beforeEach } from 'vitest'

// ── import module under test ──────────────────────────────────────────────────
const mod = await import('../public/quick-reply.js')
const {
  BUILT_IN_CHIPS,
  addChip,
  removeChip,
  reorderChip,
  updateChip,
  loadPalette,
  savePalette,
  mountQuickReply,
  PALETTE_KEY,
} = mod

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

function makeChip(over: Partial<import('../public/quick-reply.js').Chip> = {}): import('../public/quick-reply.js').Chip {
  return {
    id: 'test-id',
    text: 'hello',
    label: 'hello',
    appendEnter: true,
    ...over,
  }
}

beforeEach(() => {
  localStorage.clear()
  vi.restoreAllMocks()
})

// ─────────────────────────── BUILT_IN_CHIPS ──────────────────────────────────

describe('BUILT_IN_CHIPS', () => {
  it('exports exactly 6 built-in chips', () => {
    expect(BUILT_IN_CHIPS).toHaveLength(6)
  })

  it('contains yes, continue, 1, 2, 3, Esc (in that order)', () => {
    const labels = BUILT_IN_CHIPS.map((c) => c.label)
    expect(labels).toContain('yes')
    expect(labels).toContain('continue')
    expect(labels).toContain('1')
    expect(labels).toContain('2')
    expect(labels).toContain('3')
    expect(labels).toContain('Esc')
  })

  it('yes chip has appendEnter=true and text "yes"', () => {
    const yes = BUILT_IN_CHIPS.find((c) => c.label === 'yes')
    expect(yes).toBeDefined()
    expect(yes!.appendEnter).toBe(true)
    expect(yes!.text).toBe('yes')
  })

  it('continue chip has appendEnter=true and text "continue"', () => {
    const cont = BUILT_IN_CHIPS.find((c) => c.label === 'continue')
    expect(cont).toBeDefined()
    expect(cont!.appendEnter).toBe(true)
    expect(cont!.text).toBe('continue')
  })

  it('1/2/3 chips have appendEnter=true', () => {
    for (const label of ['1', '2', '3']) {
      const chip = BUILT_IN_CHIPS.find((c) => c.label === label)
      expect(chip).toBeDefined()
      expect(chip!.appendEnter).toBe(true)
    }
  })

  it('Esc chip has appendEnter=false and text \\x1b', () => {
    const esc = BUILT_IN_CHIPS.find((c) => c.label === 'Esc')
    expect(esc).toBeDefined()
    expect(esc!.appendEnter).toBe(false)
    expect(esc!.text).toBe('\x1b')
    expect(esc!.text.charCodeAt(0)).toBe(0x1b)
  })

  it('all built-in chips have unique ids', () => {
    const ids = BUILT_IN_CHIPS.map((c) => c.id)
    expect(new Set(ids).size).toBe(6)
  })

  it('is frozen / immutable (modification attempt does not affect original)', () => {
    // Reading from a frozen array should work; appending returns new array
    const extended = [...BUILT_IN_CHIPS, makeChip({ id: 'extra' })]
    expect(extended).toHaveLength(7)
    expect(BUILT_IN_CHIPS).toHaveLength(6)
  })
})

// ─────────────────────────── addChip ─────────────────────────────────────────

describe('addChip', () => {
  it('returns a new array with the chip appended', () => {
    const chips = [makeChip({ id: 'a' })]
    const newChip = makeChip({ id: 'b' })
    const result = addChip(chips, newChip)
    expect(result).toHaveLength(2)
    expect(result[1]).toBe(newChip)
  })

  it('does NOT mutate the original array', () => {
    const chips = [makeChip({ id: 'a' })]
    const original = [...chips]
    addChip(chips, makeChip({ id: 'b' }))
    expect(chips).toHaveLength(original.length)
    expect(chips[0]).toBe(original[0])
  })

  it('works on an empty array', () => {
    const chip = makeChip({ id: 'first' })
    const result = addChip([], chip)
    expect(result).toHaveLength(1)
    expect(result[0]).toBe(chip)
  })

  it('returns a different array reference', () => {
    const chips = [makeChip()]
    const result = addChip(chips, makeChip({ id: 'x' }))
    expect(result).not.toBe(chips)
  })
})

// ─────────────────────────── removeChip ──────────────────────────────────────

describe('removeChip', () => {
  it('removes the chip with the matching id', () => {
    const a = makeChip({ id: 'a' })
    const b = makeChip({ id: 'b' })
    const c = makeChip({ id: 'c' })
    const result = removeChip([a, b, c], 'b')
    expect(result).toHaveLength(2)
    expect(result.find((ch) => ch.id === 'b')).toBeUndefined()
  })

  it('does NOT mutate the original array', () => {
    const chips = [makeChip({ id: 'a' }), makeChip({ id: 'b' })]
    removeChip(chips, 'a')
    expect(chips).toHaveLength(2)
  })

  it('returns a different array reference', () => {
    const chips = [makeChip({ id: 'a' })]
    expect(removeChip(chips, 'a')).not.toBe(chips)
  })

  it('no-op when id not found — length unchanged', () => {
    const chips = [makeChip({ id: 'a' })]
    const result = removeChip(chips, 'nonexistent')
    expect(result).toHaveLength(1)
  })

  it('removes only the matched id when duplicates do not exist', () => {
    const a = makeChip({ id: 'a', label: 'A' })
    const b = makeChip({ id: 'b', label: 'B' })
    const result = removeChip([a, b], 'a')
    expect(result).toHaveLength(1)
    expect(result[0]!.label).toBe('B')
  })
})

// ─────────────────────────── reorderChip ─────────────────────────────────────

describe('reorderChip', () => {
  it('moves a chip from fromIndex to toIndex', () => {
    const a = makeChip({ id: 'a' })
    const b = makeChip({ id: 'b' })
    const c = makeChip({ id: 'c' })
    const result = reorderChip([a, b, c], 0, 2)
    expect(result.map((ch) => ch.id)).toEqual(['b', 'c', 'a'])
  })

  it('does NOT mutate the original array', () => {
    const chips = [makeChip({ id: 'a' }), makeChip({ id: 'b' })]
    reorderChip(chips, 0, 1)
    expect(chips[0]!.id).toBe('a')
  })

  it('returns a different array reference', () => {
    const chips = [makeChip({ id: 'a' }), makeChip({ id: 'b' })]
    expect(reorderChip(chips, 0, 1)).not.toBe(chips)
  })

  it('moves to the same position (no-op reorder)', () => {
    const a = makeChip({ id: 'a' })
    const b = makeChip({ id: 'b' })
    const result = reorderChip([a, b], 0, 0)
    expect(result.map((ch) => ch.id)).toEqual(['a', 'b'])
  })

  it('handles out-of-bounds fromIndex gracefully', () => {
    const chips = [makeChip({ id: 'a' })]
    const result = reorderChip(chips, 99, 0)
    expect(result).toHaveLength(1)
  })

  it('handles out-of-bounds toIndex gracefully', () => {
    const chips = [makeChip({ id: 'a' })]
    const result = reorderChip(chips, 0, 99)
    expect(result).toHaveLength(1)
  })
})

// ─────────────────────────── updateChip ──────────────────────────────────────

describe('updateChip', () => {
  it('updates the chip with matching id (partial patch)', () => {
    const chip = makeChip({ id: 'x', label: 'before' })
    const result = updateChip([chip], 'x', { label: 'after' })
    expect(result[0]!.label).toBe('after')
    expect(result[0]!.id).toBe('x') // id preserved
  })

  it('does NOT mutate the original chip object', () => {
    const chip = makeChip({ id: 'x', label: 'original' })
    updateChip([chip], 'x', { label: 'changed' })
    expect(chip.label).toBe('original')
  })

  it('does NOT mutate the original array', () => {
    const chips = [makeChip({ id: 'x' })]
    updateChip(chips, 'x', { label: 'new' })
    expect(chips).toHaveLength(1)
  })

  it('returns a different array reference', () => {
    const chips = [makeChip({ id: 'x' })]
    expect(updateChip(chips, 'x', {})).not.toBe(chips)
  })

  it('leaves non-matching chips unchanged', () => {
    const a = makeChip({ id: 'a', label: 'A' })
    const b = makeChip({ id: 'b', label: 'B' })
    const result = updateChip([a, b], 'a', { label: 'A2' })
    expect(result[1]!.label).toBe('B')
    expect(result[1]).toBe(b)
  })

  it('no-op when id not found', () => {
    const chip = makeChip({ id: 'x', label: 'X' })
    const result = updateChip([chip], 'nonexistent', { label: 'changed' })
    expect(result[0]!.label).toBe('X')
  })

  it('can update appendEnter', () => {
    const chip = makeChip({ id: 'x', appendEnter: true })
    const result = updateChip([chip], 'x', { appendEnter: false })
    expect(result[0]!.appendEnter).toBe(false)
  })
})

// ─────────────────────────── loadPalette ─────────────────────────────────────

describe('loadPalette', () => {
  it('returns empty array when localStorage has no key', () => {
    expect(loadPalette()).toEqual([])
  })

  it('returns the stored chips on valid JSON', () => {
    const chips = [makeChip({ id: 'p1', text: 'run tests', label: 'run tests', appendEnter: true })]
    localStorage.setItem(PALETTE_KEY, JSON.stringify(chips))
    const result = loadPalette()
    expect(result).toHaveLength(1)
    expect(result[0]!.id).toBe('p1')
    expect(result[0]!.text).toBe('run tests')
  })

  it('does NOT throw on bad JSON', () => {
    localStorage.setItem(PALETTE_KEY, 'not-valid-json{{{')
    expect(() => loadPalette()).not.toThrow()
    expect(loadPalette()).toEqual([])
  })

  it('does NOT throw when localStorage contains a non-array JSON value', () => {
    localStorage.setItem(PALETTE_KEY, JSON.stringify({ foo: 'bar' }))
    expect(() => loadPalette()).not.toThrow()
    expect(loadPalette()).toEqual([])
  })

  it('returns empty array when stored data has items with missing fields', () => {
    const bad = [{ id: 'x' }] // missing text/label/appendEnter
    localStorage.setItem(PALETTE_KEY, JSON.stringify(bad))
    expect(() => loadPalette()).not.toThrow()
    // Either returns [] or filters invalid items
    const result = loadPalette()
    expect(Array.isArray(result)).toBe(true)
    const valid = result.filter((c) => typeof c.id === 'string' && typeof c.text === 'string')
    expect(valid.length).toBe(0)
  })

  it('returns empty array when stored data is null JSON', () => {
    localStorage.setItem(PALETTE_KEY, 'null')
    expect(loadPalette()).toEqual([])
  })

  it('does NOT throw when localStorage throws (simulated private mode)', () => {
    vi.spyOn(Storage.prototype, 'getItem').mockImplementation(() => {
      throw new Error('Storage blocked in private mode')
    })
    expect(() => loadPalette()).not.toThrow()
    expect(loadPalette()).toEqual([])
  })
})

// ─────────────────────────── savePalette ─────────────────────────────────────

describe('savePalette', () => {
  it('saves chips to localStorage', () => {
    const chips = [makeChip({ id: 'saved' })]
    savePalette(chips)
    const raw = localStorage.getItem(PALETTE_KEY)
    expect(raw).toBeTruthy()
    const parsed = JSON.parse(raw!)
    expect(parsed).toHaveLength(1)
    expect(parsed[0].id).toBe('saved')
  })

  it('saves an empty array without throwing', () => {
    expect(() => savePalette([])).not.toThrow()
    const raw = localStorage.getItem(PALETTE_KEY)
    expect(JSON.parse(raw!)).toEqual([])
  })

  it('does NOT throw when localStorage is unavailable', () => {
    vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => {
      throw new Error('Storage quota exceeded')
    })
    expect(() => savePalette([makeChip()])).not.toThrow()
  })
})

// ─────────────────────────── round-trip persistence ──────────────────────────

describe('savePalette + loadPalette — round-trip', () => {
  it('persists and restores a palette with multiple chips', () => {
    const chips = [
      makeChip({ id: 'a', text: 'run tests', label: 'run tests', appendEnter: true }),
      makeChip({ id: 'b', text: '/clear', label: '/clear', appendEnter: false }),
    ]
    savePalette(chips)
    const loaded = loadPalette()
    expect(loaded).toHaveLength(2)
    expect(loaded[0]!.text).toBe('run tests')
    expect(loaded[1]!.text).toBe('/clear')
    expect(loaded[1]!.appendEnter).toBe(false)
  })

  it('persists appendEnter=false correctly', () => {
    savePalette([makeChip({ id: 'x', appendEnter: false })])
    const loaded = loadPalette()
    expect(loaded[0]!.appendEnter).toBe(false)
  })
})

// ─────────────────────────── mountQuickReply ─────────────────────────────────

describe('mountQuickReply — DOM', () => {
  it('renders built-in chip buttons inside the container', () => {
    const container = document.createElement('div')
    const onSend = vi.fn()
    const handle = mountQuickReply(container, { onSend })
    const buttons = container.querySelectorAll('button.qr-chip')
    // All 6 built-in chips should be rendered
    expect(buttons.length).toBeGreaterThanOrEqual(6)
    handle.dispose()
  })

  it('clicking a built-in chip with appendEnter=true sends text + \\r', () => {
    const container = document.createElement('div')
    const onSend = vi.fn()
    const handle = mountQuickReply(container, { onSend })

    // Find the 'yes' chip button
    const yesBtn = Array.from(container.querySelectorAll('button.qr-chip')).find(
      (b) => b.textContent === 'yes',
    )
    expect(yesBtn).toBeDefined()
    ;(yesBtn as HTMLButtonElement).click()
    expect(onSend).toHaveBeenCalledWith('yes\r')
    handle.dispose()
  })

  it('clicking the Esc chip (appendEnter=false) sends \\x1b without \\r', () => {
    const container = document.createElement('div')
    const onSend = vi.fn()
    const handle = mountQuickReply(container, { onSend })

    const escBtn = Array.from(container.querySelectorAll('button.qr-chip')).find(
      (b) => b.textContent === 'Esc',
    )
    expect(escBtn).toBeDefined()
    ;(escBtn as HTMLButtonElement).click()
    expect(onSend).toHaveBeenCalledWith('\x1b')
    expect(onSend).not.toHaveBeenCalledWith('\x1b\r')
    handle.dispose()
  })

  it('user palette chips are also rendered', () => {
    localStorage.setItem(
      PALETTE_KEY,
      JSON.stringify([makeChip({ id: 'u1', label: 'run tests', text: 'run tests', appendEnter: true })]),
    )
    const container = document.createElement('div')
    const onSend = vi.fn()
    const handle = mountQuickReply(container, { onSend })

    const buttons = Array.from(container.querySelectorAll('button.qr-chip'))
    const found = buttons.find((b) => b.textContent === 'run tests')
    expect(found).toBeDefined()
    handle.dispose()
  })

  it('user palette chip click sends correct text', () => {
    localStorage.setItem(
      PALETTE_KEY,
      JSON.stringify([makeChip({ id: 'u1', label: 'commit', text: 'git commit -m', appendEnter: false })]),
    )
    const container = document.createElement('div')
    const onSend = vi.fn()
    const handle = mountQuickReply(container, { onSend })

    const commitBtn = Array.from(container.querySelectorAll('button.qr-chip')).find(
      (b) => b.textContent === 'commit',
    )
    ;(commitBtn as HTMLButtonElement).click()
    expect(onSend).toHaveBeenCalledWith('git commit -m')
    handle.dispose()
  })

  it('chip labels with <script> appear as plain text — not injected as HTML (AC-A3.4 / SEC-L3)', () => {
    localStorage.setItem(
      PALETTE_KEY,
      JSON.stringify([
        makeChip({ id: 'xss', label: '<script>alert(1)</script>', text: 'safe', appendEnter: false }),
      ]),
    )
    const container = document.createElement('div')
    const onSend = vi.fn()
    const handle = mountQuickReply(container, { onSend })

    // No <script> element should be injected into the DOM
    expect(container.querySelectorAll('script').length).toBe(0)

    // The label text should appear as inert text
    const xssBtn = Array.from(container.querySelectorAll('button.qr-chip')).find(
      (b) => b.textContent === '<script>alert(1)</script>',
    )
    expect(xssBtn).toBeDefined()
    handle.dispose()
  })

  it('contains a + button to add a new snippet', () => {
    const container = document.createElement('div')
    const onSend = vi.fn()
    const handle = mountQuickReply(container, { onSend })
    const addBtn = container.querySelector('.qr-add-btn')
    expect(addBtn).not.toBeNull()
    handle.dispose()
  })

  it('dispose removes chip buttons from the container', () => {
    const container = document.createElement('div')
    const handle = mountQuickReply(container, { onSend: vi.fn() })
    expect(container.children.length).toBeGreaterThan(0)
    handle.dispose()
    expect(container.children.length).toBe(0)
  })

  it('inline editor appears when + is clicked', () => {
    const container = document.createElement('div')
    const handle = mountQuickReply(container, { onSend: vi.fn() })
    const addBtn = container.querySelector('.qr-add-btn') as HTMLButtonElement
    addBtn.click()
    expect(container.querySelector('.qr-editor')).not.toBeNull()
    handle.dispose()
  })

  it('inline editor cancel button removes the editor', () => {
    const container = document.createElement('div')
    const handle = mountQuickReply(container, { onSend: vi.fn() })
    const addBtn = container.querySelector('.qr-add-btn') as HTMLButtonElement
    addBtn.click()
    const cancelBtn = container.querySelector('.qr-editor-cancel') as HTMLButtonElement
    cancelBtn.click()
    expect(container.querySelector('.qr-editor')).toBeNull()
    handle.dispose()
  })

  it('saving a new chip from editor persists it to localStorage and re-renders', () => {
    const container = document.createElement('div')
    const handle = mountQuickReply(container, { onSend: vi.fn() })
    const addBtn = container.querySelector('.qr-add-btn') as HTMLButtonElement
    addBtn.click()

    const textInput = container.querySelector('.qr-editor-text') as HTMLInputElement
    textInput.value = 'write tests'
    const labelInput = container.querySelector('.qr-editor-label') as HTMLInputElement
    labelInput.value = 'write tests'

    const saveBtn = container.querySelector('.qr-editor-save') as HTMLButtonElement
    saveBtn.click()

    // Editor should be gone
    expect(container.querySelector('.qr-editor')).toBeNull()
    // New chip should be in localStorage
    const stored = loadPalette()
    expect(stored.some((c) => c.text === 'write tests')).toBe(true)
    // New chip button should appear
    const newBtn = Array.from(container.querySelectorAll('button.qr-chip')).find(
      (b) => b.textContent === 'write tests',
    )
    expect(newBtn).toBeDefined()
    handle.dispose()
  })

  it('editor save with empty text is a no-op', () => {
    const container = document.createElement('div')
    const handle = mountQuickReply(container, { onSend: vi.fn() })
    const countBefore = loadPalette().length

    const addBtn = container.querySelector('.qr-add-btn') as HTMLButtonElement
    addBtn.click()
    // Leave text empty
    const saveBtn = container.querySelector('.qr-editor-save') as HTMLButtonElement
    saveBtn.click()

    // Editor stays open (no text to save)
    const countAfter = loadPalette().length
    expect(countAfter).toBe(countBefore)
    handle.dispose()
  })

  // ── W2: the editor's "Queue" button (only shown when onQueue is wired) ────────
  it('shows no Queue button when onQueue is not provided', () => {
    const container = document.createElement('div')
    const handle = mountQuickReply(container, { onSend: vi.fn() })
    ;(container.querySelector('.qr-add-btn') as HTMLButtonElement).click()
    expect(container.querySelector('.qr-editor-queue')).toBeNull()
    handle.dispose()
  })

  it('Queue button calls onQueue with the typed text + appendEnter and closes the editor', () => {
    const container = document.createElement('div')
    const onQueue = vi.fn()
    const handle = mountQuickReply(container, { onSend: vi.fn(), onQueue })
    ;(container.querySelector('.qr-add-btn') as HTMLButtonElement).click()

    const textInput = container.querySelector('.qr-editor-text') as HTMLInputElement
    textInput.value = 'run tests then open a PR'
    const enterCheck = container.querySelector('.qr-editor-enter') as HTMLInputElement
    enterCheck.checked = true

    ;(container.querySelector('.qr-editor-queue') as HTMLButtonElement).click()

    expect(onQueue).toHaveBeenCalledWith('run tests then open a PR', true)
    // Enqueue must NOT persist a chip nor send immediately.
    expect(loadPalette().some((c) => c.text === 'run tests then open a PR')).toBe(false)
    expect(container.querySelector('.qr-editor')).toBeNull()
    handle.dispose()
  })

  it('Queue button with empty text is a no-op (onQueue not called, editor stays)', () => {
    const container = document.createElement('div')
    const onQueue = vi.fn()
    const handle = mountQuickReply(container, { onSend: vi.fn(), onQueue })
    ;(container.querySelector('.qr-add-btn') as HTMLButtonElement).click()
    ;(container.querySelector('.qr-editor-queue') as HTMLButtonElement).click()

    expect(onQueue).not.toHaveBeenCalled()
    expect(container.querySelector('.qr-editor')).not.toBeNull()
    handle.dispose()
  })
})
