// @vitest-environment jsdom
/**
 * test/tabs.test.ts — frontend TabApp unit tests.
 *
 * Mocks TerminalSession + the launcher + projects panel so we test TabApp's
 * tab lifecycle in isolation: the v0.5 "close last tab → launcher" invariant,
 * that addEntry builds a real (non-null) session before pushing the entry
 * (Phase 1#5), and the v0.6 openProject / home segmented-control wiring (P6).
 */

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

// ── Mock TerminalSession ──────────────────────────────────────────────────────
let constructed = 0
class FakeTerminalSession {
  el: HTMLDivElement
  id: string | null
  status = 'connecting'
  claudeStatus = 'unknown'
  cwd: string | null = null
  pendingApproval = false
  pendingTool: string | undefined = undefined
  /** B4: which gate the held approval is — 'tool' (two buttons) or 'plan' (three). */
  pendingGate: 'tool' | 'plan' | null = null
  /** VC: nonce that counts false→true pendingApproval flips (stale-gate guard). */
  pendingEpoch = 0
  /** B2: latest statusLine telemetry (single source of truth). */
  telemetry: unknown = null
  /** Captured so tests can assert initialInput ends with \r (Finding 1). */
  initialInput: string | undefined = undefined
  connect = vi.fn()
  dispose = vi.fn()
  show = vi.fn()
  hide = vi.fn()
  applyTheme = vi.fn()
  refit = vi.fn()
  send = vi.fn()
  approve = vi.fn()
  reject = vi.fn()
  findNext = vi.fn()
  findPrevious = vi.fn()
  clearSearch = vi.fn()
  // Captured callbacks so tests can drive status/title/activity events.
  cbs: {
    onClaudeStatus?: (s: string, d?: string) => void
    onStatus?: (s: string) => void
    onActivity?: () => void
    onTitle?: (t: string) => void
    onTelemetry?: (t: unknown) => void
  }
  static instances: FakeTerminalSession[] = []
  constructor(opts: {
    sessionId: string | null
    initialInput?: string
    onClaudeStatus?: (s: string, d?: string) => void
    onStatus?: (s: string) => void
    onActivity?: () => void
    onTitle?: (t: string) => void
    onTelemetry?: (t: unknown) => void
    [key: string]: unknown
  }) {
    constructed += 1
    this.id = opts.sessionId
    this.initialInput = opts.initialInput
    this.el = document.createElement('div')
    this.cbs = opts
    FakeTerminalSession.instances.push(this)
  }
}

vi.mock('../public/terminal-session.js', () => ({ TerminalSession: FakeTerminalSession }))

// ── Mock the v0.7 panels (push / quick-reply / timeline / voice / gauge) ──────
const renderTelemetryGauge = vi.fn()
vi.mock('../public/preview-grid.js', () => ({ renderTelemetryGauge }))

const mountPushToggle = vi.fn()
vi.mock('../public/push.js', () => ({ mountPushToggle }))

const quickReply: { onSend?: (d: string) => void } = {}
const mountQuickReply = vi.fn((_c: HTMLElement, opts: { onSend: (d: string) => void }) => {
  quickReply.onSend = opts.onSend
  return { dispose: vi.fn() }
})
vi.mock('../public/quick-reply.js', () => ({ mountQuickReply }))

const timelineDispose = vi.fn()
const mountTimeline = vi.fn(() => ({ dispose: timelineDispose }))
vi.mock('../public/timeline.js', () => ({ mountTimeline }))

const voice: {
  onTranscript?: (t: string, confidence?: number) => void
  onInterim?: (t: string) => void
} = {}
const voiceStart = vi.fn()
const voiceStop = vi.fn()
const createVoiceInput = vi.fn(
  (
    onTranscript: (t: string, confidence?: number) => void,
    opts?: { onInterim?: (t: string) => void },
  ) => {
    voice.onTranscript = onTranscript
    voice.onInterim = opts?.onInterim
    return { start: voiceStart, stop: voiceStop, dispose: vi.fn(), isActive: () => false }
  },
)
const isSpeechSupported = vi.fn(() => true)
vi.mock('../public/voice.js', () => ({ createVoiceInput, isSpeechSupported }))

// ── Mock the launcher (records visibility) ────────────────────────────────────
const launcherVisible = { value: false }
const mountLauncher = vi.fn(() => ({
  setVisible: (v: boolean) => {
    launcherVisible.value = v
  },
  refresh: vi.fn(),
}))
vi.mock('../public/launcher.js', () => ({ mountLauncher }))

// ── Mock the projects panel (records visibility) ──────────────────────────────
const projectsVisible = { value: false }
const mountProjects = vi.fn(() => ({
  setVisible: (v: boolean) => {
    projectsVisible.value = v
  },
  refresh: vi.fn(),
}))
vi.mock('../public/projects.js', () => ({ mountProjects }))

// settings.js is light but imports nothing heavy; let it load for real.

const { TabApp } = await import('../public/tabs.js')

function makeHosts(): { paneHost: HTMLElement; tabBar: HTMLElement } {
  const paneHost = document.createElement('div')
  const tabBar = document.createElement('div')
  // #keybar must exist so the quick-reply row mounts directly above it (A3).
  const keybar = document.createElement('div')
  keybar.id = 'keybar'
  document.body.append(paneHost, tabBar, keybar)
  return { paneHost, tabBar }
}

/** Stub fetch('/config/ui') → { allowAutoMode }. */
function stubUiConfigFetch(allowAutoMode: boolean): void {
  vi.stubGlobal(
    'fetch',
    vi.fn(async () => ({ ok: true, json: async () => ({ allowAutoMode }) })),
  )
}

beforeEach(() => {
  constructed = 0
  FakeTerminalSession.instances = []
  launcherVisible.value = false
  projectsVisible.value = false
  document.body.replaceChildren()
  localStorage.clear()
  renderTelemetryGauge.mockClear()
  mountPushToggle.mockClear()
  mountQuickReply.mockClear()
  mountTimeline.mockClear()
  timelineDispose.mockClear()
  createVoiceInput.mockClear()
  voiceStart.mockClear()
  voiceStop.mockClear()
  quickReply.onSend = undefined
  voice.onTranscript = undefined
  voice.onInterim = undefined
  // Default: server forbids 'auto' (loadUiConfig resolves to allowAutoMode=false).
  vi.stubGlobal('fetch', vi.fn(async () => { throw new Error('no fetch in test') }))
})

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

describe('TabApp — v0.5 launcher chooser', () => {
  it('starts with NO auto-created tab and shows the launcher', () => {
    const { paneHost, tabBar } = makeHosts()
    new TabApp(paneHost, tabBar)
    // No TerminalSession constructed on boot (no auto tab).
    expect(constructed).toBe(0)
    expect(launcherVisible.value).toBe(true)
  })

  it('newTab() creates a tab and hides the launcher', () => {
    const { paneHost, tabBar } = makeHosts()
    const app = new TabApp(paneHost, tabBar)
    app.newTab()
    expect(constructed).toBe(1)
    expect(launcherVisible.value).toBe(false)
  })

  it('closing the last tab returns to the launcher (no auto-blank tab)', () => {
    const { paneHost, tabBar } = makeHosts()
    const app = new TabApp(paneHost, tabBar)
    app.newTab()
    expect(launcherVisible.value).toBe(false)
    app.closeTab(0)
    // Back to the chooser, and no new tab was auto-created.
    expect(launcherVisible.value).toBe(true)
    expect(constructed).toBe(1)
  })
})

describe('TabApp — addEntry has no null-session hole (Phase 1#5)', () => {
  it('the constructed session is real and used immediately (connect called)', () => {
    const { paneHost, tabBar } = makeHosts()
    const app = new TabApp(paneHost, tabBar)
    app.newTab()
    // The entry's session must be the constructed FakeTerminalSession with connect() called.
    const snap = app.snapshot()
    expect(snap).toHaveLength(1)
    // sendToActive routes to a real session (would throw if session were null).
    expect(() => app.sendToActive('x')).not.toThrow()
  })

  it('openSession joins by id and focuses it', () => {
    const { paneHost, tabBar } = makeHosts()
    const app = new TabApp(paneHost, tabBar)
    app.openSession('11111111-1111-4111-8111-111111111111')
    expect(constructed).toBe(1)
    expect(app.activeSessionId()).toBe('11111111-1111-4111-8111-111111111111')
  })

  it('openSession re-focuses an already-open session instead of duplicating', () => {
    const { paneHost, tabBar } = makeHosts()
    const app = new TabApp(paneHost, tabBar)
    const id = '22222222-2222-4222-8222-222222222222'
    app.openSession(id)
    app.openSession(id)
    expect(constructed).toBe(1) // not duplicated
  })
})

describe('TabApp — multi-tab lifecycle', () => {
  it('renders a tab bar with one .tab per tab plus the add button', () => {
    const { paneHost, tabBar } = makeHosts()
    const app = new TabApp(paneHost, tabBar)
    app.newTab()
    app.newTab()
    expect(tabBar.querySelectorAll('.tab')).toHaveLength(2)
    expect(tabBar.querySelector('.tab-add')).not.toBeNull()
  })

  it('activate() switches the active tab (show/hide on sessions)', () => {
    const { paneHost, tabBar } = makeHosts()
    const app = new TabApp(paneHost, tabBar)
    app.newTab()
    app.newTab()
    app.activate(0)
    const snap = app.snapshot()
    expect(snap.find((t) => t.active)?.idx).toBe(0)
  })

  it('closeTab on a middle tab keeps the others and re-activates a neighbor', () => {
    const { paneHost, tabBar } = makeHosts()
    const app = new TabApp(paneHost, tabBar)
    app.newTab()
    app.newTab()
    app.newTab()
    app.closeTab(1)
    expect(app.snapshot()).toHaveLength(2)
  })

  it('applySettings re-themes every tab without throwing', () => {
    const { paneHost, tabBar } = makeHosts()
    const app = new TabApp(paneHost, tabBar)
    app.newTab()
    expect(() => app.applySettings({ theme: 'dark', fontSize: 15 })).not.toThrow()
  })

  it('findInActive / clearActiveSearch route to the active session', () => {
    const { paneHost, tabBar } = makeHosts()
    const app = new TabApp(paneHost, tabBar)
    app.newTab()
    expect(() => {
      app.findInActive('q', 'next')
      app.findInActive('q', 'prev')
      app.clearActiveSearch()
      app.refitActive()
    }).not.toThrow()
  })

  it('newTabForResume opens a tab seeded with a resume command', () => {
    const { paneHost, tabBar } = makeHosts()
    const app = new TabApp(paneHost, tabBar)
    app.newTabForResume('/work', '33333333-3333-4333-8333-333333333333')
    expect(constructed).toBe(1)
    expect(app.snapshot()).toHaveLength(1)
  })

  it('newTabForResume initialInput ends with \\r so the shell auto-executes it (Finding 1)', () => {
    const { paneHost, tabBar } = makeHosts()
    const app = new TabApp(paneHost, tabBar)
    app.newTabForResume('/work', '33333333-3333-4333-8333-333333333333')
    const session = FakeTerminalSession.instances[0]!
    expect(session.initialInput).toBe('claude --resume 33333333-3333-4333-8333-333333333333\r')
  })

  it('openProject default initialInput is "claude\\r" so the shell auto-executes it (A3/Finding 1)', () => {
    const { paneHost, tabBar } = makeHosts()
    const app = new TabApp(paneHost, tabBar)
    app.openProject('/path/to/myrepo', 'myrepo')
    const session = FakeTerminalSession.instances[0]!
    expect(session.initialInput).toBe('claude\r')
  })

  it('snapshot reflects per-tab connection + claude status fields', () => {
    const { paneHost, tabBar } = makeHosts()
    const app = new TabApp(paneHost, tabBar)
    app.newTab()
    const snap = app.snapshot()
    expect(snap[0]).toMatchObject({ idx: 0, conn: 'connecting', claude: 'unknown' })
  })
})

describe('TabApp — DOM interactions on the tab bar', () => {
  function pointerdown(elm: Element): void {
    elm.dispatchEvent(new MouseEvent('pointerdown', { bubbles: true }))
  }

  it('pointerdown on a tab activates it', () => {
    const { paneHost, tabBar } = makeHosts()
    const app = new TabApp(paneHost, tabBar)
    app.newTab()
    app.newTab()
    const tabs = tabBar.querySelectorAll('.tab')
    pointerdown(tabs[0]!)
    expect(app.snapshot().find((t) => t.active)?.idx).toBe(0)
  })

  it('clicking the × close button closes that tab', () => {
    const { paneHost, tabBar } = makeHosts()
    const app = new TabApp(paneHost, tabBar)
    app.newTab()
    app.newTab()
    const close = tabBar.querySelector('.tab-close') as HTMLButtonElement
    close.dispatchEvent(new MouseEvent('click', { bubbles: true }))
    expect(app.snapshot()).toHaveLength(1)
  })

  it('clicking the + add button opens a new tab', () => {
    const { paneHost, tabBar } = makeHosts()
    const app = new TabApp(paneHost, tabBar)
    const add = tabBar.querySelector('.tab-add') as HTMLButtonElement
    add.click()
    expect(app.snapshot()).toHaveLength(1)
  })

  it('middle-click (auxclick button 1) closes a tab', () => {
    const { paneHost, tabBar } = makeHosts()
    const app = new TabApp(paneHost, tabBar)
    app.newTab()
    app.newTab()
    const tab = tabBar.querySelector('.tab') as HTMLElement
    tab.dispatchEvent(new MouseEvent('auxclick', { bubbles: true, button: 1 }))
    expect(app.snapshot()).toHaveLength(1)
  })

  it('double-click the label enters rename mode, Enter commits a custom title', () => {
    const { paneHost, tabBar } = makeHosts()
    const app = new TabApp(paneHost, tabBar)
    app.newTab()
    const label = tabBar.querySelector('.tab-label') as HTMLElement
    label.dispatchEvent(new MouseEvent('dblclick', { bubbles: true }))
    const input = tabBar.querySelector('input.tab-rename') as HTMLInputElement
    expect(input).not.toBeNull()
    input.value = 'My Tab'
    input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }))
    expect(app.snapshot()[0]!.title).toBe('My Tab')
  })

  it('activate() out of range is ignored', () => {
    const { paneHost, tabBar } = makeHosts()
    const app = new TabApp(paneHost, tabBar)
    app.newTab()
    expect(() => {
      app.activate(99)
      app.activate(-1)
      app.focusTab(0)
    }).not.toThrow()
  })

  it('sendToActive / clearActiveSearch with no active tab do not throw', () => {
    const { paneHost, tabBar } = makeHosts()
    const app = new TabApp(paneHost, tabBar)
    // No tab open (launcher visible).
    expect(() => {
      app.sendToActive('x')
      app.clearActiveSearch()
      app.refitActive()
    }).not.toThrow()
    expect(app.activeSessionId()).toBeNull()
  })

  it('Escape in rename mode cancels without committing', () => {
    const { paneHost, tabBar } = makeHosts()
    const app = new TabApp(paneHost, tabBar)
    app.newTab()
    const label = tabBar.querySelector('.tab-label') as HTMLElement
    label.dispatchEvent(new MouseEvent('dblclick', { bubbles: true }))
    const input = tabBar.querySelector('input.tab-rename') as HTMLInputElement
    input.value = 'discard me'
    input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }))
    // Title falls back to the auto/default, not the typed value.
    expect(app.snapshot()[0]!.title).not.toBe('discard me')
  })

  it('onTitle/onStatus/onActivity callbacks refresh the tab in place', () => {
    const { paneHost, tabBar } = makeHosts()
    const app = new TabApp(paneHost, tabBar)
    app.newTab()
    const fake = FakeTerminalSession.instances[0]!
    // Drive the captured callbacks like the real TerminalSession would.
    expect(() => {
      fake.cbs.onTitle?.('myproj')
      fake.cbs.onStatus?.('connected')
      fake.cbs.onActivity?.()
    }).not.toThrow()
  })

  it('onClaudeStatus "waiting" on a background tab notifies (H2/H4)', () => {
    const NotificationMock = vi.fn()
    ;(NotificationMock as unknown as { permission: string }).permission = 'granted'
    vi.stubGlobal('Notification', NotificationMock)
    const { paneHost, tabBar } = makeHosts()
    const app = new TabApp(paneHost, tabBar)
    app.newTab() // tab 0 (active)
    app.newTab() // tab 1 (active now)
    // tab 0 is in the background; fire its onClaudeStatus 'waiting'.
    const bg = FakeTerminalSession.instances[0]!
    bg.claudeStatus = 'waiting'
    bg.cbs.onClaudeStatus?.('waiting')
    expect(NotificationMock).toHaveBeenCalled()
    vi.unstubAllGlobals()
  })

  it('drag-and-drop reorders tabs', () => {
    const { paneHost, tabBar } = makeHosts()
    const app = new TabApp(paneHost, tabBar)
    app.newTab()
    app.newTab()
    const tabs = tabBar.querySelectorAll('.tab')
    // jsdom has no DataTransfer; the handlers use optional chaining + this.dragIndex,
    // so plain drag events (no dataTransfer) still drive the reorder path.
    tabs[0]!.dispatchEvent(new Event('dragstart', { bubbles: true }))
    tabs[1]!.dispatchEvent(new Event('dragover', { bubbles: true }))
    tabs[1]!.dispatchEvent(new Event('drop', { bubbles: true }))
    tabs[0]!.dispatchEvent(new Event('dragend', { bubbles: true }))
    expect(app.snapshot()).toHaveLength(2)
  })
})

describe('TabApp — P6 openProject + home segmented control', () => {
  it('openProject creates a tab with the repo name as its displayed title', () => {
    const { paneHost, tabBar } = makeHosts()
    const app = new TabApp(paneHost, tabBar)
    app.openProject('/path/to/myrepo', 'myrepo')
    expect(app.snapshot()).toHaveLength(1)
    expect(app.snapshot()[0]!.title).toBe('myrepo')
  })

  it('a second openProject with the same name gets a #2 suffix', () => {
    const { paneHost, tabBar } = makeHosts()
    const app = new TabApp(paneHost, tabBar)
    app.openProject('/path/to/myrepo', 'myrepo')
    app.openProject('/path/to/myrepo', 'myrepo')
    const snap = app.snapshot()
    expect(snap).toHaveLength(2)
    expect(snap[0]!.title).toBe('myrepo')
    expect(snap[1]!.title).toBe('myrepo #2')
  })

  it('countOpenWithTitlePrefix increments correctly for three tabs with the same base name', () => {
    const { paneHost, tabBar } = makeHosts()
    const app = new TabApp(paneHost, tabBar)
    app.openProject('/p', 'proj')
    app.openProject('/p', 'proj')
    app.openProject('/p', 'proj')
    const snap = app.snapshot()
    expect(snap[0]!.title).toBe('proj')
    expect(snap[1]!.title).toBe('proj #2')
    expect(snap[2]!.title).toBe('proj #3')
  })

  it('openProject hides the segmented control and both panels (tab open)', () => {
    const { paneHost, tabBar } = makeHosts()
    const app = new TabApp(paneHost, tabBar)
    app.openProject('/p/repo', 'repo')
    const seg = paneHost.querySelector('.home-seg') as HTMLElement
    expect(seg).not.toBeNull()
    expect(seg.style.display).toBe('none')
    expect(launcherVisible.value).toBe(false)
    expect(projectsVisible.value).toBe(false)
  })

  it('home segmented control shows both panels depending on selected view', () => {
    const { paneHost, tabBar } = makeHosts()
    new TabApp(paneHost, tabBar)

    // Initially: sessions view → launcher visible, projects hidden.
    expect(launcherVisible.value).toBe(true)
    expect(projectsVisible.value).toBe(false)

    // Click the "Projects" button (second .home-seg-btn).
    const projBtn = paneHost.querySelectorAll('.home-seg-btn')[1] as HTMLButtonElement
    projBtn.click()
    expect(launcherVisible.value).toBe(false)
    expect(projectsVisible.value).toBe(true)

    // Click the "Sessions" button (first .home-seg-btn).
    const sessBtn = paneHost.querySelectorAll('.home-seg-btn')[0] as HTMLButtonElement
    sessBtn.click()
    expect(launcherVisible.value).toBe(true)
    expect(projectsVisible.value).toBe(false)
  })

  it('closing the last tab shows the segmented control and restores home view', () => {
    const { paneHost, tabBar } = makeHosts()
    const app = new TabApp(paneHost, tabBar)
    app.newTab()
    const seg = paneHost.querySelector('.home-seg') as HTMLElement
    expect(seg.style.display).toBe('none') // hidden while tab open

    app.closeTab(0)
    expect(seg.style.display).not.toBe('none') // back to home
    expect(launcherVisible.value).toBe(true) // sessions view by default
  })

  it('openProject with an empty repoPath still opens a tab', () => {
    const { paneHost, tabBar } = makeHosts()
    const app = new TabApp(paneHost, tabBar)
    expect(() => app.openProject('', 'bare-repo')).not.toThrow()
    expect(app.snapshot()).toHaveLength(1)
    expect(app.snapshot()[0]!.title).toBe('bare-repo')
  })
})

describe('TabApp — Home (⌂) button: reach the chooser from an open session', () => {
  it('overlays the home chooser over an open tab, then toggles back', () => {
    const { paneHost, tabBar } = makeHosts()
    const app = new TabApp(paneHost, tabBar)
    app.newTab()
    const seg = paneHost.querySelector('.home-seg') as HTMLElement
    const homeBtn = tabBar.querySelector('.tab-home') as HTMLButtonElement
    expect(homeBtn).not.toBeNull()
    expect(seg.style.display).toBe('none') // terminal owns the screen

    homeBtn.click() // overlay the home chooser
    expect(seg.style.display).toBe('flex')
    expect(launcherVisible.value).toBe(true) // default sessions view
    expect(homeBtn.classList.contains('active')).toBe(true)

    homeBtn.click() // back to the terminal
    expect(seg.style.display).toBe('none')
    expect(launcherVisible.value).toBe(false)
    expect(homeBtn.classList.contains('active')).toBe(false)
  })

  it('can switch to Projects from the overlay and open another project', () => {
    const { paneHost, tabBar } = makeHosts()
    const app = new TabApp(paneHost, tabBar)
    app.openProject('/p/one', 'one')

    ;(tabBar.querySelector('.tab-home') as HTMLButtonElement).click() // overlay home
    ;(paneHost.querySelectorAll('.home-seg-btn')[1] as HTMLButtonElement).click() // Projects
    expect(projectsVisible.value).toBe(true)

    app.openProject('/p/two', 'two') // opening dismisses the overlay
    expect(app.snapshot()).toHaveLength(2)
    const seg = paneHost.querySelector('.home-seg') as HTMLElement
    expect(seg.style.display).toBe('none')
    expect(projectsVisible.value).toBe(false)
    expect((tabBar.querySelector('.tab-home') as HTMLButtonElement).classList.contains('active')).toBe(false)
  })

  it('⌂ is a no-op when no tab is open (home already shown)', () => {
    const { paneHost, tabBar } = makeHosts()
    new TabApp(paneHost, tabBar)
    const seg = paneHost.querySelector('.home-seg') as HTMLElement
    expect(seg.style.display).toBe('flex')
    ;(tabBar.querySelector('.tab-home') as HTMLButtonElement).click()
    expect(seg.style.display).toBe('flex') // still shown, no crash
  })
})

describe('TabApp — v0.7 panels mount once (A1 push / A3 quick-reply)', () => {
  it('mounts the 🔔 push toggle exactly once on construction (A1)', () => {
    const { paneHost, tabBar } = makeHosts()
    new TabApp(paneHost, tabBar)
    expect(mountPushToggle).toHaveBeenCalledTimes(1)
  })

  it('mounts the quick-reply row directly above #keybar (A3)', () => {
    const { paneHost, tabBar } = makeHosts()
    new TabApp(paneHost, tabBar)
    expect(mountQuickReply).toHaveBeenCalledTimes(1)
    const qr = document.getElementById('quickreply')
    expect(qr).not.toBeNull()
    expect(qr?.nextElementSibling).toBe(document.getElementById('keybar'))
  })

  it('quick-reply onSend routes bytes to the active session (A3-FR3)', () => {
    const { paneHost, tabBar } = makeHosts()
    const app = new TabApp(paneHost, tabBar)
    app.newTab()
    quickReply.onSend?.('yes\r')
    expect(FakeTerminalSession.instances[0]!.send).toHaveBeenCalledWith('yes\r')
  })

  it('the push bell survives a tab-bar rebuild (re-parented, not re-mounted)', () => {
    const { paneHost, tabBar } = makeHosts()
    const app = new TabApp(paneHost, tabBar)
    app.newTab() // rebuild
    app.newTab() // rebuild again
    expect(mountPushToggle).toHaveBeenCalledTimes(1)
    expect(tabBar.querySelector('.push-host')).not.toBeNull()
  })
})

describe('TabApp — B2 telemetry gauge + A5 stuck badge (SP10/M4)', () => {
  it('renders the telemetry gauge from the session.telemetry single source of truth', () => {
    const { paneHost, tabBar } = makeHosts()
    const app = new TabApp(paneHost, tabBar)
    app.newTab()
    const session = FakeTerminalSession.instances[0]!
    const tele = { at: Date.now(), costUsd: 0.5, contextUsedPct: 40 }
    session.telemetry = tele
    renderTelemetryGauge.mockClear()
    session.cbs.onTelemetry?.(tele) // server pushed new telemetry
    expect(renderTelemetryGauge).toHaveBeenCalled()
    expect(renderTelemetryGauge.mock.calls.at(-1)![1]).toBe(tele)
  })

  it('a stuck claudeStatus shows ⚠ + the claude-stuck class on a background tab (AC-A5.5)', () => {
    const { paneHost, tabBar } = makeHosts()
    const app = new TabApp(paneHost, tabBar)
    app.newTab() // tab 0
    app.newTab() // tab 1 active → tab 0 background
    const bg = FakeTerminalSession.instances[0]!
    bg.claudeStatus = 'stuck'
    bg.cbs.onClaudeStatus?.('stuck')
    const tab0 = tabBar.querySelectorAll('.tab')[0]!
    expect(tab0.classList.contains('claude-stuck')).toBe(true)
    expect(tab0.querySelector('.tab-claude')?.textContent).toBe('⚠')
  })
})

describe('TabApp — B4 plan-gate approval bar', () => {
  function openWithGate(gate: 'tool' | 'plan', tool?: string): FakeTerminalSession {
    const { paneHost, tabBar } = makeHosts()
    const app = new TabApp(paneHost, tabBar)
    app.newTab()
    const session = FakeTerminalSession.instances.at(-1)!
    session.pendingApproval = true
    session.pendingGate = gate
    session.pendingTool = tool
    session.cbs.onClaudeStatus?.('waiting')
    return session
  }

  it('a tool gate renders exactly two buttons (no regression, AC-B4.3)', () => {
    openWithGate('tool', 'Bash')
    const bar = document.getElementById('approvalbar')!
    expect(bar.style.display).toBe('flex')
    expect(bar.querySelectorAll('button')).toHaveLength(2)
  })

  it('a plan gate maps three buttons to acceptEdits / default / reject (AC-B4.2)', () => {
    const session = openWithGate('plan')
    const bar = document.getElementById('approvalbar')!
    const click = (i: number): void => (bar.querySelectorAll('button')[i] as HTMLButtonElement).click()
    expect(bar.querySelectorAll('button')).toHaveLength(3)
    click(0) // Approve + Auto
    expect(session.approve).toHaveBeenLastCalledWith('acceptEdits')
    click(1) // Approve + Review
    expect(session.approve).toHaveBeenLastCalledWith('default')
    click(2) // Keep Planning
    expect(session.reject).toHaveBeenCalled()
  })
})

describe('TabApp — B4 permission-mode relay', () => {
  it('openProject with a non-default mode upgrades the launch command (AC-B4.1)', () => {
    const { paneHost, tabBar } = makeHosts()
    const app = new TabApp(paneHost, tabBar)
    app.openProject('/p', 'repo', 'claude\r', 'plan')
    expect(FakeTerminalSession.instances.at(-1)!.initialInput).toBe(
      'claude --permission-mode plan\r',
    )
  })

  it('openProject with default mode keeps the plain claude command (no regression)', () => {
    const { paneHost, tabBar } = makeHosts()
    const app = new TabApp(paneHost, tabBar)
    app.openProject('/p', 'repo', 'claude\r', 'default')
    expect(FakeTerminalSession.instances.at(-1)!.initialInput).toBe('claude\r')
  })

  it('availablePermissionModes hides "auto" until the server allows it (SEC-M5/AC-B4.4)', () => {
    const { paneHost, tabBar } = makeHosts()
    const app = new TabApp(paneHost, tabBar) // default fetch stub → allowAutoMode false
    expect(app.availablePermissionModes()).toEqual(['default', 'acceptEdits', 'plan'])
  })

  it('availablePermissionModes includes "auto" once /config/ui allows it', async () => {
    stubUiConfigFetch(true)
    const { paneHost, tabBar } = makeHosts()
    const app = new TabApp(paneHost, tabBar)
    await vi.waitFor(() => expect(app.availablePermissionModes()).toContain('auto'))
  })

  it('loadDefaultMode downgrades stored "auto" to "default" when the server forbids it', () => {
    localStorage.setItem('web-terminal:permission-mode', 'auto')
    const { paneHost, tabBar } = makeHosts()
    const app = new TabApp(paneHost, tabBar) // allowAutoMode stays false
    expect(app.loadDefaultMode()).toBe('default')
  })

  it('saveDefaultMode round-trips a valid mode; invalid stored value falls back', () => {
    const { paneHost, tabBar } = makeHosts()
    const app = new TabApp(paneHost, tabBar)
    app.saveDefaultMode('plan')
    expect(app.loadDefaultMode()).toBe('plan')
    localStorage.setItem('web-terminal:permission-mode', 'garbage')
    expect(app.loadDefaultMode()).toBe('default')
  })
})

describe('TabApp — A4 timeline panel', () => {
  const SID = '44444444-4444-4444-8444-444444444444'

  it('toggling 📜 mounts the timeline for the active session, then disposes on close', () => {
    const { paneHost, tabBar } = makeHosts()
    const app = new TabApp(paneHost, tabBar)
    app.openSession(SID)
    const tlBtn = tabBar.querySelector('.tab-timeline') as HTMLButtonElement
    expect(tlBtn).not.toBeNull()

    tlBtn.click() // open
    expect(mountTimeline).toHaveBeenCalledTimes(1)
    expect(document.getElementById('timeline-panel')!.style.display).toBe('block')

    tlBtn.click() // close
    expect(timelineDispose).toHaveBeenCalled()
    expect(document.getElementById('timeline-panel')!.style.display).toBe('none')
  })

  it('closing a tab with its timeline open disposes the polling handle', () => {
    const { paneHost, tabBar } = makeHosts()
    const app = new TabApp(paneHost, tabBar)
    app.openSession(SID)
    ;(tabBar.querySelector('.tab-timeline') as HTMLButtonElement).click() // open
    expect(mountTimeline).toHaveBeenCalled()
    app.closeTab(0)
    expect(timelineDispose).toHaveBeenCalled()
  })
})

describe('TabApp — A2 push-to-talk voice', () => {
  it('handleVoiceTrigger("start") creates a recognizer and shows the interim overlay', () => {
    const { paneHost, tabBar } = makeHosts()
    const app = new TabApp(paneHost, tabBar)
    app.handleVoiceTrigger('start')
    expect(createVoiceInput).toHaveBeenCalledTimes(1)
    expect(voiceStart).toHaveBeenCalled()
    expect(document.getElementById('voice-interim')!.style.display).toBe('block')
  })

  it('a final transcript is sent to the active session as raw input (A2-FR1)', () => {
    const { paneHost, tabBar } = makeHosts()
    const app = new TabApp(paneHost, tabBar)
    app.newTab()
    app.handleVoiceTrigger('start')
    voice.onTranscript?.('list the files')
    expect(FakeTerminalSession.instances[0]!.send).toHaveBeenCalledWith('list the files')
  })

  it('interim text updates the overlay; "stop" hides it (A2-FR2)', () => {
    const { paneHost, tabBar } = makeHosts()
    const app = new TabApp(paneHost, tabBar)
    app.handleVoiceTrigger('start')
    voice.onInterim?.('hello wor')
    const overlay = document.getElementById('voice-interim')!
    expect(overlay.textContent).toBe('hello wor')
    app.handleVoiceTrigger('stop')
    expect(voiceStop).toHaveBeenCalled()
    expect(overlay.style.display).toBe('none')
  })

  it('voice is a no-op when SpeechRecognition is unsupported (AC-A2.3)', () => {
    createVoiceInput.mockReturnValueOnce(null as never)
    const { paneHost, tabBar } = makeHosts()
    const app = new TabApp(paneHost, tabBar)
    expect(() => app.handleVoiceTrigger('start')).not.toThrow()
    expect(voiceStart).not.toHaveBeenCalled()
  })
})

describe('TabApp — VC voice command mapping (context-gated confirm/reject)', () => {
  /** Open one tab, hold a tool-gate approval, start PTT, and return its session. */
  function pendingToolSession(app: InstanceType<typeof TabApp>): FakeTerminalSession {
    app.newTab()
    const session = FakeTerminalSession.instances[0]!
    session.status = 'connected'
    session.pendingApproval = true
    session.pendingGate = 'tool'
    app.handleVoiceTrigger('start')
    return session
  }

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

  it('a held tool-gate "confirm" arms the approve window, then commits once after it elapses (send NOT called)', () => {
    vi.useFakeTimers()
    const { paneHost, tabBar } = makeHosts()
    const app = new TabApp(paneHost, tabBar)
    const session = pendingToolSession(app)

    voice.onTranscript?.('confirm', 0.9)
    expect(session.approve).not.toHaveBeenCalled() // armed, not yet committed

    vi.advanceTimersByTime(1500)
    expect(session.approve).toHaveBeenCalledTimes(1)
    expect(session.send).not.toHaveBeenCalled()
  })

  it('a held tool-gate "拒绝" rejects immediately (fail-safe, no confirm window)', () => {
    const { paneHost, tabBar } = makeHosts()
    const app = new TabApp(paneHost, tabBar)
    const session = pendingToolSession(app)

    voice.onTranscript?.('拒绝')
    expect(session.reject).toHaveBeenCalledTimes(1)
    expect(session.send).not.toHaveBeenCalled()
  })

  it('an unmatched phrase ("deploy to prod") falls through to dictation even while pending', () => {
    const { paneHost, tabBar } = makeHosts()
    const app = new TabApp(paneHost, tabBar)
    const session = pendingToolSession(app)

    voice.onTranscript?.('deploy to prod')
    expect(session.send).toHaveBeenCalledWith('deploy to prod')
    expect(session.approve).not.toHaveBeenCalled()
    expect(session.reject).not.toHaveBeenCalled()
  })

  it('pending=false: "yes" is ordinary dictation (decision 1 — context gate)', () => {
    const { paneHost, tabBar } = makeHosts()
    const app = new TabApp(paneHost, tabBar)
    app.newTab()
    const session = FakeTerminalSession.instances[0]!
    session.status = 'connected'
    session.pendingApproval = false
    app.handleVoiceTrigger('start')

    voice.onTranscript?.('yes')
    expect(session.send).toHaveBeenCalledWith('yes')
    expect(session.approve).not.toHaveBeenCalled()
  })

  it('plan gate: "confirm" falls through to dictation (decision A — tool-gate only)', () => {
    const { paneHost, tabBar } = makeHosts()
    const app = new TabApp(paneHost, tabBar)
    app.newTab()
    const session = FakeTerminalSession.instances[0]!
    session.status = 'connected'
    session.pendingApproval = true
    session.pendingGate = 'plan'
    app.handleVoiceTrigger('start')

    voice.onTranscript?.('confirm')
    expect(session.send).toHaveBeenCalledWith('confirm')
    expect(session.approve).not.toHaveBeenCalled()
  })

  it('stale gate: an epoch flip after PTT-start downgrades an approve to dictation (safeguard 5)', () => {
    const { paneHost, tabBar } = makeHosts()
    const app = new TabApp(paneHost, tabBar)
    const session = pendingToolSession(app) // captures pendingEpoch=0 at PTT-start
    session.pendingEpoch = 1 // a new pending flip landed mid-utterance

    voice.onTranscript?.('confirm', 0.9)
    expect(session.send).toHaveBeenCalledWith('confirm')
    expect(session.approve).not.toHaveBeenCalled()
  })

  it('tab switch: activating a different tab after PTT-start downgrades an approve to dictation (safeguard 5)', () => {
    const { paneHost, tabBar } = makeHosts()
    const app = new TabApp(paneHost, tabBar)
    const session0 = pendingToolSession(app) // PTT started while tab 0 is active

    app.newTab() // tab 1 becomes active mid-utterance
    const session1 = FakeTerminalSession.instances[1]!
    session1.status = 'connected'
    session1.pendingApproval = true
    session1.pendingGate = 'tool'

    voice.onTranscript?.('confirm', 0.9)
    expect(session1.send).toHaveBeenCalledWith('confirm')
    expect(session1.approve).not.toHaveBeenCalled()
    expect(session0.approve).not.toHaveBeenCalled()
  })

  it('WS not connected: approve is never sent and never throws; flashes "未连接" (decision C)', () => {
    const { paneHost, tabBar } = makeHosts()
    const app = new TabApp(paneHost, tabBar)
    app.newTab()
    const session = FakeTerminalSession.instances[0]!
    session.status = 'connecting' // not connected
    session.pendingApproval = true
    session.pendingGate = 'tool'
    app.handleVoiceTrigger('start')

    expect(() => voice.onTranscript?.('确认', 0.9)).not.toThrow()
    expect(session.approve).not.toHaveBeenCalled()
    const overlay = document.getElementById('voice-confirm')
    expect(overlay?.textContent).toBe('未连接')
    expect(overlay?.style.display).toBe('block')
  })

  it('WS not connected: reject is never sent and never throws (decision C)', () => {
    const { paneHost, tabBar } = makeHosts()
    const app = new TabApp(paneHost, tabBar)
    app.newTab()
    const session = FakeTerminalSession.instances[0]!
    session.status = 'reconnecting' // not connected
    session.pendingApproval = true
    session.pendingGate = 'tool'
    app.handleVoiceTrigger('start')

    expect(() => voice.onTranscript?.('拒绝')).not.toThrow()
    expect(session.reject).not.toHaveBeenCalled()
  })

  it('a confidence below the threshold downgrades an approve phrase to dictation (decision D)', () => {
    const { paneHost, tabBar } = makeHosts()
    const app = new TabApp(paneHost, tabBar)
    const session = pendingToolSession(app)

    voice.onTranscript?.('confirm', 0.4)
    expect(session.send).toHaveBeenCalledWith('confirm')
    expect(session.approve).not.toHaveBeenCalled()
  })

  // ── confirm-window cancellation + commit-time re-validation (review fixes) ──

  it('a "拒绝" spoken during the confirm window cancels the pending approve (reject wins)', () => {
    vi.useFakeTimers()
    const { paneHost, tabBar } = makeHosts()
    const app = new TabApp(paneHost, tabBar)
    const session = pendingToolSession(app)

    voice.onTranscript?.('confirm', 0.9) // arm the window
    voice.onTranscript?.('拒绝') // change of mind mid-window
    expect(session.reject).toHaveBeenCalledTimes(1)

    vi.advanceTimersByTime(1500)
    expect(session.approve).not.toHaveBeenCalled() // stale timer must NOT commit
  })

  it('tapping the confirm overlay cancels the pending approve before it commits', () => {
    vi.useFakeTimers()
    const { paneHost, tabBar } = makeHosts()
    const app = new TabApp(paneHost, tabBar)
    const session = pendingToolSession(app)

    voice.onTranscript?.('confirm', 0.9)
    document.getElementById('voice-confirm')!.click() // tap-to-cancel (§4)

    vi.advanceTimersByTime(1500)
    expect(session.approve).not.toHaveBeenCalled()
    expect(document.getElementById('voice-confirm')!.style.display).toBe('none')
  })

  it('a new dictation utterance during the window supersedes (cancels) the pending approve', () => {
    vi.useFakeTimers()
    const { paneHost, tabBar } = makeHosts()
    const app = new TabApp(paneHost, tabBar)
    const session = pendingToolSession(app)

    voice.onTranscript?.('confirm', 0.9)
    voice.onTranscript?.('deploy to prod') // user moved on

    vi.advanceTimersByTime(1500)
    expect(session.approve).not.toHaveBeenCalled()
    expect(session.send).toHaveBeenCalledWith('deploy to prod')
  })

  it('an epoch flip DURING the confirm window drops the commit (TOCTOU re-validation)', () => {
    vi.useFakeTimers()
    const { paneHost, tabBar } = makeHosts()
    const app = new TabApp(paneHost, tabBar)
    const session = pendingToolSession(app) // epoch 0 at PTT-start AND at dispatch

    voice.onTranscript?.('confirm', 0.9) // arms against epoch 0
    session.pendingEpoch = 1 // gate A resolved + gate B raised during the window

    vi.advanceTimersByTime(1500)
    expect(session.approve).not.toHaveBeenCalled() // must not approve the new gate
  })

  it('a WS drop DURING the confirm window drops the commit (no local/server desync)', () => {
    vi.useFakeTimers()
    const { paneHost, tabBar } = makeHosts()
    const app = new TabApp(paneHost, tabBar)
    const session = pendingToolSession(app)

    voice.onTranscript?.('confirm', 0.9)
    session.status = 'reconnecting' // socket dropped mid-window

    vi.advanceTimersByTime(1500)
    expect(session.approve).not.toHaveBeenCalled()
  })

  it('re-confirming within the window re-arms and commits exactly once', () => {
    vi.useFakeTimers()
    const { paneHost, tabBar } = makeHosts()
    const app = new TabApp(paneHost, tabBar)
    const session = pendingToolSession(app)

    voice.onTranscript?.('confirm', 0.9)
    vi.advanceTimersByTime(500)
    voice.onTranscript?.('confirm', 0.9) // supersede first, re-arm

    vi.advanceTimersByTime(1500)
    expect(session.approve).toHaveBeenCalledTimes(1)
  })
})
