// @vitest-environment jsdom
/**
 * test/voice.test.ts — Unit tests for voice.ts (N-voice, A2).
 *
 * Tests the Web Speech API wrapper (isSpeechSupported / createVoiceInput)
 * and the keybar mic-button integration (mountKeybar opts.onVoiceTrigger).
 * Runs under jsdom so window / document / TouchEvent are available; the real
 * SpeechRecognition is replaced by a lightweight self-registering mock class.
 *
 * AC-A2.1  final transcript calls onTranscript
 * AC-A2.2  touch preventDefault keeps soft keyboard hidden
 * AC-A2.3  unsupported → hidden, no crash
 * AC-A2.4  autoSend appends \r; off → no \r
 * AC-A2.5  audio never reaches server (pure-FE — no server stub needed here)
 * SEC-L2   disclosure via title attribute on mic button
 */

import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { isSpeechSupported, createVoiceInput } from '../public/voice.js'
import { mountKeybar } from '../public/keybar.js'

// ── Mock SpeechRecognition ────────────────────────────────────────────────────
// Using a self-registering class pattern: each `new MockRecognition()` stores
// itself in MockRecognition.last, so tests can reference the instance created
// by createVoiceInput() without needing vi.fn() as the constructor wrapper.

/** Minimal SpeechRecognitionEvent-like shape for test usage. */
interface MockResultEvent {
  resultIndex: number
  results: {
    length: number
    [index: number]: {
      isFinal: boolean
      length: number
      [index: number]: { transcript: string; confidence: number }
    }
  }
}

class MockRecognition {
  /** The most-recently constructed instance — set in the constructor. */
  static last: MockRecognition | null = null

  continuous = false
  interimResults = false
  lang = ''
  onresult: ((ev: MockResultEvent) => void) | null = null
  onend: (() => void) | null = null
  onerror: ((ev: { error: string }) => void) | null = null

  // Arrow-function class fields create fresh vi.fn() per instance
  readonly start = vi.fn()
  readonly stop = vi.fn()
  readonly abort = vi.fn()

  constructor() {
    MockRecognition.last = this
  }
}

/** Build a single-result event for testing. */
function makeResultEvent(transcript: string, isFinal: boolean, resultIndex = 0): MockResultEvent {
  const alt = { transcript, confidence: 1.0 }
  const result = Object.assign([alt], { isFinal, length: 1 })
  const results = Object.assign([result], { length: 1 })
  return { resultIndex, results } as unknown as MockResultEvent
}

// ─────────────────────────────────────────────────────────────────────────────
// isSpeechSupported
// ─────────────────────────────────────────────────────────────────────────────

describe('isSpeechSupported', () => {
  afterEach(() => {
    vi.unstubAllGlobals()
  })

  it('returns false when SpeechRecognition is not in window', () => {
    // jsdom does not provide SpeechRecognition by default
    expect(isSpeechSupported()).toBe(false)
  })

  it('returns true when SpeechRecognition is present in window', () => {
    vi.stubGlobal('SpeechRecognition', MockRecognition)
    expect(isSpeechSupported()).toBe(true)
  })

  it('returns true when webkitSpeechRecognition is present in window', () => {
    vi.stubGlobal('webkitSpeechRecognition', MockRecognition)
    expect(isSpeechSupported()).toBe(true)
  })
})

// ─────────────────────────────────────────────────────────────────────────────
// createVoiceInput
// ─────────────────────────────────────────────────────────────────────────────

describe('createVoiceInput', () => {
  beforeEach(() => {
    MockRecognition.last = null
    vi.stubGlobal('SpeechRecognition', MockRecognition)
  })

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

  // --- null when unsupported ---

  it('returns null when speech is not supported', () => {
    vi.unstubAllGlobals()
    const voice = createVoiceInput(vi.fn())
    expect(voice).toBeNull()
  })

  // --- object shape ---

  it('returns a VoiceInput object with required methods', () => {
    const voice = createVoiceInput(vi.fn())
    expect(voice).not.toBeNull()
    expect(typeof voice?.start).toBe('function')
    expect(typeof voice?.stop).toBe('function')
    expect(typeof voice?.dispose).toBe('function')
    expect(typeof voice?.isActive).toBe('function')
  })

  // --- isActive ---

  it('starts in inactive state', () => {
    const voice = createVoiceInput(vi.fn())
    expect(voice?.isActive()).toBe(false)
  })

  it('isActive returns true after start()', () => {
    const voice = createVoiceInput(vi.fn())
    voice?.start()
    expect(voice?.isActive()).toBe(true)
  })

  it('isActive returns false after stop()', () => {
    const voice = createVoiceInput(vi.fn())
    voice?.start()
    voice?.stop()
    expect(voice?.isActive()).toBe(false)
  })

  it('isActive returns false after natural onend fires', () => {
    const voice = createVoiceInput(vi.fn())
    const mock = MockRecognition.last!
    voice?.start()
    mock.onend?.()
    expect(voice?.isActive()).toBe(false)
  })

  it('isActive returns false after onerror fires', () => {
    const voice = createVoiceInput(vi.fn())
    const mock = MockRecognition.last!
    voice?.start()
    mock.onerror?.({ error: 'network' })
    expect(voice?.isActive()).toBe(false)
  })

  // --- recognition.start / stop calls ---

  it('calls recognition.start() on start()', () => {
    const voice = createVoiceInput(vi.fn())
    const mock = MockRecognition.last!
    voice?.start()
    expect(mock.start).toHaveBeenCalledTimes(1)
  })

  it('does not call recognition.start() twice when already active', () => {
    const voice = createVoiceInput(vi.fn())
    const mock = MockRecognition.last!
    voice?.start()
    voice?.start()
    expect(mock.start).toHaveBeenCalledTimes(1)
  })

  it('calls recognition.stop() on stop() when active', () => {
    const voice = createVoiceInput(vi.fn())
    const mock = MockRecognition.last!
    voice?.start()
    voice?.stop()
    expect(mock.stop).toHaveBeenCalledTimes(1)
  })

  it('does not call recognition.stop() when not active', () => {
    const voice = createVoiceInput(vi.fn())
    const mock = MockRecognition.last!
    voice?.stop()
    expect(mock.stop).not.toHaveBeenCalled()
  })

  // --- transcript callbacks ---

  it('calls onTranscript with final transcript text (AC-A2.1)', () => {
    const onTranscript = vi.fn()
    const voice = createVoiceInput(onTranscript)
    const mock = MockRecognition.last!
    voice?.start()
    mock.onresult?.(makeResultEvent('hello world', true))
    expect(onTranscript).toHaveBeenCalledWith('hello world', expect.any(Number))
  })

  it('autoSend=true appends \\r to the transcript (AC-A2.4)', () => {
    const onTranscript = vi.fn()
    const voice = createVoiceInput(onTranscript, { autoSend: true })
    const mock = MockRecognition.last!
    voice?.start()
    mock.onresult?.(makeResultEvent('hello', true))
    expect(onTranscript).toHaveBeenCalledWith('hello\r', expect.any(Number))
  })

  it('autoSend=false does not append \\r (AC-A2.4)', () => {
    const onTranscript = vi.fn()
    const voice = createVoiceInput(onTranscript, { autoSend: false })
    const mock = MockRecognition.last!
    voice?.start()
    mock.onresult?.(makeResultEvent('hello', true))
    expect(onTranscript).toHaveBeenCalledWith('hello', expect.any(Number))
  })

  it('omitting autoSend does not append \\r', () => {
    const onTranscript = vi.fn()
    const voice = createVoiceInput(onTranscript)
    const mock = MockRecognition.last!
    voice?.start()
    mock.onresult?.(makeResultEvent('hello', true))
    expect(onTranscript).toHaveBeenCalledWith('hello', expect.any(Number))
  })

  it('forwards the top alternative ASR confidence as a 2nd arg (VC decision D)', () => {
    const onTranscript = vi.fn()
    const voice = createVoiceInput(onTranscript)
    const mock = MockRecognition.last!
    voice?.start()
    mock.onresult?.(makeResultEvent('hello', true))
    expect(onTranscript).toHaveBeenCalledWith('hello', 1.0)
  })

  it('calls onInterim with interim transcript text', () => {
    const onInterim = vi.fn()
    const onTranscript = vi.fn()
    const voice = createVoiceInput(onTranscript, { onInterim })
    const mock = MockRecognition.last!
    voice?.start()
    mock.onresult?.(makeResultEvent('hel', false))
    expect(onInterim).toHaveBeenCalledWith('hel')
    expect(onTranscript).not.toHaveBeenCalled()
  })

  it('does not crash when interim fires but onInterim is not provided', () => {
    const onTranscript = vi.fn()
    const voice = createVoiceInput(onTranscript)
    const mock = MockRecognition.last!
    voice?.start()
    expect(() => mock.onresult?.(makeResultEvent('par', false))).not.toThrow()
    expect(onTranscript).not.toHaveBeenCalled()
  })

  it('does not call onTranscript for interim-only results', () => {
    const onTranscript = vi.fn()
    const voice = createVoiceInput(onTranscript, { onInterim: vi.fn() })
    const mock = MockRecognition.last!
    voice?.start()
    mock.onresult?.(makeResultEvent('partial', false))
    expect(onTranscript).not.toHaveBeenCalled()
  })

  // --- dispose ---

  it('does not fire onTranscript after dispose()', () => {
    const onTranscript = vi.fn()
    const voice = createVoiceInput(onTranscript)
    const mock = MockRecognition.last!
    voice?.start()
    voice?.dispose()
    mock.onresult?.(makeResultEvent('late transcript', true))
    expect(onTranscript).not.toHaveBeenCalled()
  })

  it('calls recognition.abort() when disposed while active', () => {
    const voice = createVoiceInput(vi.fn())
    const mock = MockRecognition.last!
    voice?.start()
    voice?.dispose()
    expect(mock.abort).toHaveBeenCalledTimes(1)
  })

  it('does not call recognition.abort() when disposed while inactive', () => {
    const voice = createVoiceInput(vi.fn())
    const mock = MockRecognition.last!
    voice?.dispose()
    expect(mock.abort).not.toHaveBeenCalled()
  })

  it('dispose() is idempotent — double dispose is safe', () => {
    const voice = createVoiceInput(vi.fn())
    const mock = MockRecognition.last!
    voice?.start()
    expect(() => { voice?.dispose(); voice?.dispose() }).not.toThrow()
    expect(mock.abort).toHaveBeenCalledTimes(1)
  })

  it('start() after dispose() is a no-op', () => {
    const voice = createVoiceInput(vi.fn())
    const mock = MockRecognition.last!
    voice?.dispose()
    voice?.start()
    expect(mock.start).not.toHaveBeenCalled()
  })

  // --- configuration ---

  it('sets recognition.lang to the provided lang option', () => {
    createVoiceInput(vi.fn(), { lang: 'ja-JP' })
    expect(MockRecognition.last?.lang).toBe('ja-JP')
  })

  it('uses navigator.language by default', () => {
    createVoiceInput(vi.fn())
    expect(MockRecognition.last?.lang).toBe(navigator.language)
  })

  it('sets interimResults=true when onInterim is provided', () => {
    createVoiceInput(vi.fn(), { onInterim: vi.fn() })
    expect(MockRecognition.last?.interimResults).toBe(true)
  })

  it('sets interimResults=false when onInterim is not provided', () => {
    createVoiceInput(vi.fn())
    expect(MockRecognition.last?.interimResults).toBe(false)
  })

  it('uses webkitSpeechRecognition when SpeechRecognition is absent', () => {
    vi.unstubAllGlobals()
    // Re-create MockRecognition.last tracking
    class WebkitMockRecognition extends MockRecognition {}
    vi.stubGlobal('webkitSpeechRecognition', WebkitMockRecognition)

    const onTranscript = vi.fn()
    const voice = createVoiceInput(onTranscript)
    expect(voice).not.toBeNull()
    voice?.start()
    expect(MockRecognition.last?.start).toHaveBeenCalled()
    vi.unstubAllGlobals()
  })
})

// ─────────────────────────────────────────────────────────────────────────────
// mountKeybar voice button (onVoiceTrigger integration)
// ─────────────────────────────────────────────────────────────────────────────

describe('mountKeybar voice button', () => {
  beforeEach(() => {
    document.body.innerHTML = '<div id="keybar"></div>'
  })

  afterEach(() => {
    document.body.innerHTML = ''
    vi.unstubAllGlobals()
  })

  it('does not append a mic button when speech is not supported (AC-A2.3)', () => {
    // No SpeechRecognition in window by default in jsdom
    mountKeybar(vi.fn(), { onVoiceTrigger: vi.fn() })
    expect(document.querySelector('[data-key="voice"]')).toBeNull()
  })

  it('does not append a mic button when onVoiceTrigger is not provided', () => {
    vi.stubGlobal('SpeechRecognition', MockRecognition)
    mountKeybar(vi.fn())
    expect(document.querySelector('[data-key="voice"]')).toBeNull()
  })

  it('appends a mic button when supported and onVoiceTrigger is provided', () => {
    vi.stubGlobal('SpeechRecognition', MockRecognition)
    mountKeybar(vi.fn(), { onVoiceTrigger: vi.fn() })
    expect(document.querySelector('[data-key="voice"]')).not.toBeNull()
  })

  it('mic button displays 🎤 key label', () => {
    vi.stubGlobal('SpeechRecognition', MockRecognition)
    mountKeybar(vi.fn(), { onVoiceTrigger: vi.fn() })
    const keyEl = document.querySelector('[data-key="voice"] .kb-key')
    expect(keyEl?.textContent).toBe('🎤')
  })

  it('mic button has a caption label', () => {
    vi.stubGlobal('SpeechRecognition', MockRecognition)
    mountKeybar(vi.fn(), { onVoiceTrigger: vi.fn() })
    const capEl = document.querySelector('[data-key="voice"] .kb-cap')
    expect(capEl?.textContent).toBeTruthy()
  })

  it('touchstart calls onVoiceTrigger("start") and prevents default (AC-A2.2)', () => {
    vi.stubGlobal('SpeechRecognition', MockRecognition)
    const onVoiceTrigger = vi.fn()
    mountKeybar(vi.fn(), { onVoiceTrigger })
    const btn = document.querySelector('[data-key="voice"]')!
    const ev = new Event('touchstart', { cancelable: true, bubbles: true })
    btn.dispatchEvent(ev)
    expect(onVoiceTrigger).toHaveBeenCalledWith('start')
  })

  it('touchend calls onVoiceTrigger("stop") and prevents default', () => {
    vi.stubGlobal('SpeechRecognition', MockRecognition)
    const onVoiceTrigger = vi.fn()
    mountKeybar(vi.fn(), { onVoiceTrigger })
    const btn = document.querySelector('[data-key="voice"]')!
    const ev = new Event('touchend', { cancelable: true, bubbles: true })
    btn.dispatchEvent(ev)
    expect(onVoiceTrigger).toHaveBeenCalledWith('stop')
  })

  it('mousedown calls onVoiceTrigger("start") for desktop fallback', () => {
    vi.stubGlobal('SpeechRecognition', MockRecognition)
    const onVoiceTrigger = vi.fn()
    mountKeybar(vi.fn(), { onVoiceTrigger })
    const btn = document.querySelector('[data-key="voice"]')!
    btn.dispatchEvent(new MouseEvent('mousedown', { cancelable: true, bubbles: true }))
    expect(onVoiceTrigger).toHaveBeenCalledWith('start')
  })

  it('mouseup calls onVoiceTrigger("stop") for desktop fallback', () => {
    vi.stubGlobal('SpeechRecognition', MockRecognition)
    const onVoiceTrigger = vi.fn()
    mountKeybar(vi.fn(), { onVoiceTrigger })
    const btn = document.querySelector('[data-key="voice"]')!
    btn.dispatchEvent(new MouseEvent('mouseup', { cancelable: true, bubbles: true }))
    expect(onVoiceTrigger).toHaveBeenCalledWith('stop')
  })

  it('mic button title discloses audio privacy (SEC-L2)', () => {
    vi.stubGlobal('SpeechRecognition', MockRecognition)
    mountKeybar(vi.fn(), { onVoiceTrigger: vi.fn() })
    const btn = document.querySelector<HTMLElement>('[data-key="voice"]')!
    expect(btn.title.toLowerCase()).toMatch(/audio/)
  })

  it('existing key buttons are still rendered when voice opts provided', () => {
    vi.stubGlobal('SpeechRecognition', MockRecognition)
    mountKeybar(vi.fn(), { onVoiceTrigger: vi.fn() })
    expect(document.querySelector('[data-key="esc"]')).not.toBeNull()
  })

  it('mountKeybar works with no opts (backward compat)', () => {
    expect(() => mountKeybar(vi.fn())).not.toThrow()
  })

  it('mountKeybar returns without error when #keybar element is absent', () => {
    document.body.innerHTML = '' // no #keybar
    expect(() => mountKeybar(vi.fn(), { onVoiceTrigger: vi.fn() })).not.toThrow()
  })
})
