// @vitest-environment jsdom
/**
 * test/worktree-form.test.ts — T-projects-ui (v0.7 Walk-away Workbench)
 *
 * Tests for the new features wired into public/projects.ts:
 *   B1: "View Diff" toggle button → mountDiffViewer inline panel
 *   B3: renderNewWorktreeForm — client-side branch validation + POST /projects/worktree
 *   A4: "Activity" section — mountTimeline per running session, dispose on onBack
 *
 * Security: SEC-H4 (diff textContent), SEC-L3/H6 (error/label textContent)
 */

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

// ── Stub @xterm/xterm (transitively via preview-grid.ts) ──────────────────────
class FakeTerminal {
  open = vi.fn()
  dispose = vi.fn()
}
vi.mock('@xterm/xterm', () => ({ Terminal: FakeTerminal }))

// ── Stub diff viewer ───────────────────────────────────────────────────────────
const mockDiffHandle = {
  showWorking: vi.fn(),
  showStaged: vi.fn(),
  close: vi.fn(),
  destroy: vi.fn(),
}
const mockMountDiffViewer = vi.fn(() => mockDiffHandle)
vi.mock('../public/diff.js', () => ({ mountDiffViewer: mockMountDiffViewer }))

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

// ── Stub PR-status chip (W3) — renderProjectDetail mounts it for git repos ──────
const mockPrChipHandle = { destroy: vi.fn() }
const mockMountPrChip = vi.fn(() => mockPrChipHandle)
vi.mock('../public/gh-chip.js', () => ({ mountPrChip: mockMountPrChip }))

// ── Import AFTER mocks ─────────────────────────────────────────────────────────
const {
  validateBranchNameClient,
  renderNewWorktreeForm,
  renderFanoutForm,
  renderProjectDetail,
  makeWorktreeRow,
  confirmAndRemoveWorktree,
  confirmAndPruneWorktrees,
} = await import('../public/projects.js')

/* ── Helpers ───────────────────────────────────────────────────────────────── */

function makeHooks() {
  return { onOpenProject: vi.fn(), onEnterSession: vi.fn(), onFanout: vi.fn() }
}

function makeCbs() {
  return { onBack: vi.fn(), onKill: vi.fn() }
}

function makeDetail(overrides: Partial<ProjectDetail> = {}): ProjectDetail {
  return {
    name: 'my-repo',
    path: '/home/user/my-repo',
    isGit: true,
    branch: 'main',
    worktrees: [],
    sessions: [],
    hasClaudeMd: false,
    ...overrides,
  }
}

beforeEach(() => {
  vi.clearAllMocks()
  vi.stubGlobal('fetch', vi.fn())
})

/* ── validateBranchNameClient ──────────────────────────────────────────────── */

describe('validateBranchNameClient', () => {
  it('returns null for a valid simple branch name', () => {
    expect(validateBranchNameClient('my-branch')).toBeNull()
    expect(validateBranchNameClient('feat-123')).toBeNull()
    expect(validateBranchNameClient('v0.7')).toBeNull()
  })

  it('returns null for namespaced branches (feature/foo)', () => {
    expect(validateBranchNameClient('feature/my-feature')).toBeNull()
    expect(validateBranchNameClient('v0.7/walk-away')).toBeNull()
    expect(validateBranchNameClient('user/fix/bug-1')).toBeNull()
  })

  it('returns an error string for an empty string', () => {
    const result = validateBranchNameClient('')
    expect(typeof result).toBe('string')
    expect(result).not.toBeNull()
  })

  it('returns an error for a name longer than 250 characters', () => {
    expect(validateBranchNameClient('a'.repeat(251))).not.toBeNull()
  })

  it('returns null for a name that is exactly 250 characters', () => {
    expect(validateBranchNameClient('a'.repeat(250))).toBeNull()
  })

  it('returns an error for a name starting with "-"', () => {
    expect(validateBranchNameClient('-bad')).not.toBeNull()
  })

  it('returns an error for a name containing ".."', () => {
    expect(validateBranchNameClient('feat..bar')).not.toBeNull()
    expect(validateBranchNameClient('..feat')).not.toBeNull()
  })

  it('returns an error for a name ending with ".lock"', () => {
    expect(validateBranchNameClient('main.lock')).not.toBeNull()
  })

  it('returns an error for a name with control characters', () => {
    expect(validateBranchNameClient('feat\x01bar')).not.toBeNull()
    expect(validateBranchNameClient('feat\x00bar')).not.toBeNull()
  })

  it('returns an error for a name with tab', () => {
    expect(validateBranchNameClient('feat\tbar')).not.toBeNull()
  })

  it('returns an error for a name with spaces', () => {
    expect(validateBranchNameClient('my branch')).not.toBeNull()
  })

  it('returns an error for a name with "~"', () => {
    expect(validateBranchNameClient('feat~1')).not.toBeNull()
  })

  it('returns an error for a name with "^"', () => {
    expect(validateBranchNameClient('feat^bar')).not.toBeNull()
  })

  it('returns an error for a name with ":"', () => {
    expect(validateBranchNameClient('feat:bar')).not.toBeNull()
  })

  it('returns an error for a name with "?"', () => {
    expect(validateBranchNameClient('feat?bar')).not.toBeNull()
  })

  it('returns an error for a name with "*"', () => {
    expect(validateBranchNameClient('feat*bar')).not.toBeNull()
  })

  it('returns an error for a name with "["', () => {
    expect(validateBranchNameClient('feat[bar')).not.toBeNull()
  })

  it('returns an error for a name with backslash', () => {
    expect(validateBranchNameClient('feat\\bar')).not.toBeNull()
  })

  it('returns an error for a name with "@{"', () => {
    expect(validateBranchNameClient('feat@{bar}')).not.toBeNull()
    expect(validateBranchNameClient('@{bar}')).not.toBeNull()
  })

  it('returns an error for a name starting with "/"', () => {
    expect(validateBranchNameClient('/feat')).not.toBeNull()
  })

  it('returns an error for a name ending with "/"', () => {
    expect(validateBranchNameClient('feat/')).not.toBeNull()
  })

  it('returns an error for a name with "//"', () => {
    expect(validateBranchNameClient('feat//bar')).not.toBeNull()
  })

  it('all invalid cases return a non-null string', () => {
    const invalids = [
      '',
      '-bad',
      'feat..bar',
      'main.lock',
      'feat\x01',
      'feat\tbar',
      'feat bar',
      'feat~1',
      'feat^x',
      'feat:x',
      'feat?x',
      'feat*x',
      'feat[x',
      'feat\\x',
      'feat@{x}',
      '/bad',
      'bad/',
      'feat//bar',
    ]
    for (const b of invalids) {
      const result = validateBranchNameClient(b)
      expect(typeof result).toBe('string')
    }
  })
})

/* ── renderNewWorktreeForm ─────────────────────────────────────────────────── */

describe('renderNewWorktreeForm', () => {
  it('renders a form container with a branch input and submit button', () => {
    const form = renderNewWorktreeForm(makeDetail(), makeHooks())
    expect(form.querySelector('input')).not.toBeNull()
    expect(form.querySelector('button')).not.toBeNull()
    // Error element exists but is hidden initially
    const errorEl = form.querySelector('.proj-wt-error') as HTMLElement | null
    expect(errorEl).not.toBeNull()
    expect(errorEl!.style.display).toBe('none')
  })

  it('shows a validation error when submitted with an empty branch name', () => {
    const hooks = makeHooks()
    const form = renderNewWorktreeForm(makeDetail(), hooks)
    ;(form.querySelector('button') as HTMLButtonElement).click()

    expect(fetch).not.toHaveBeenCalled()
    const errorEl = form.querySelector('.proj-wt-error') as HTMLElement
    expect(errorEl.style.display).not.toBe('none')
    expect(errorEl.textContent).toBeTruthy()
  })

  it('shows a validation error for an invalid branch name (leading hyphen)', () => {
    const hooks = makeHooks()
    const form = renderNewWorktreeForm(makeDetail(), hooks)
    ;(form.querySelector('input') as HTMLInputElement).value = '-bad'
    ;(form.querySelector('button') as HTMLButtonElement).click()

    expect(fetch).not.toHaveBeenCalled()
    const errorEl = form.querySelector('.proj-wt-error') as HTMLElement
    expect(errorEl.style.display).not.toBe('none')
  })

  it('POSTs to /projects/worktree with path and branch on valid input', async () => {
    const mockFetch = vi.fn().mockResolvedValue({
      ok: true,
      json: async () => ({ ok: true, path: '/home/user/my-repo-worktrees/feat', branch: 'feat' }),
    })
    vi.stubGlobal('fetch', mockFetch)

    const detail = makeDetail({ path: '/home/user/my-repo' })
    const form = renderNewWorktreeForm(detail, makeHooks())
    ;(form.querySelector('input') as HTMLInputElement).value = 'feat'
    ;(form.querySelector('button') as HTMLButtonElement).click()

    await Promise.resolve()
    await Promise.resolve()

    expect(mockFetch).toHaveBeenCalledWith(
      '/projects/worktree',
      expect.objectContaining({
        method: 'POST',
        // regression: the server (server.ts) + the docs contract read body.path,
        // NOT body.repoPath — sending repoPath 400'd every browser worktree-create.
        body: JSON.stringify({ path: '/home/user/my-repo', branch: 'feat' }),
      }),
    )
  })

  it('calls hooks.onOpenProject with the worktree path and branch on success', async () => {
    const mockFetch = vi.fn().mockResolvedValue({
      ok: true,
      json: async () => ({
        ok: true,
        path: '/home/user/my-repo-worktrees/feat',
        branch: 'feat',
      }),
    })
    vi.stubGlobal('fetch', mockFetch)

    const hooks = makeHooks()
    const form = renderNewWorktreeForm(makeDetail(), hooks)
    ;(form.querySelector('input') as HTMLInputElement).value = 'feat'
    ;(form.querySelector('button') as HTMLButtonElement).click()

    // Flush async microtasks
    await Promise.resolve()
    await Promise.resolve()
    await new Promise((r) => setTimeout(r, 0))

    expect(hooks.onOpenProject).toHaveBeenCalledWith(
      '/home/user/my-repo-worktrees/feat',
      'feat',
      'claude\r',
    )
  })

  it('falls back to repoPath/branch when response omits path/branch', async () => {
    const mockFetch = vi.fn().mockResolvedValue({
      ok: true,
      json: async () => ({ ok: true }),
    })
    vi.stubGlobal('fetch', mockFetch)

    const hooks = makeHooks()
    const detail = makeDetail({ path: '/repo' })
    const form = renderNewWorktreeForm(detail, hooks)
    ;(form.querySelector('input') as HTMLInputElement).value = 'feat'
    ;(form.querySelector('button') as HTMLButtonElement).click()

    await Promise.resolve()
    await Promise.resolve()
    await new Promise((r) => setTimeout(r, 0))

    expect(hooks.onOpenProject).toHaveBeenCalledWith('/repo', 'feat', 'claude\r')
  })

  it('shows server error message via textContent on HTTP failure', async () => {
    const mockFetch = vi.fn().mockResolvedValue({
      ok: false,
      json: async () => ({ ok: false, error: 'Branch already exists' }),
    })
    vi.stubGlobal('fetch', mockFetch)

    const hooks = makeHooks()
    const form = renderNewWorktreeForm(makeDetail(), hooks)
    ;(form.querySelector('input') as HTMLInputElement).value = 'feat'
    ;(form.querySelector('button') as HTMLButtonElement).click()

    await Promise.resolve()
    await Promise.resolve()
    await new Promise((r) => setTimeout(r, 0))

    const errorEl = form.querySelector('.proj-wt-error') as HTMLElement
    expect(errorEl.style.display).not.toBe('none')
    expect(errorEl.textContent).toBe('Branch already exists')
    expect(hooks.onOpenProject).not.toHaveBeenCalled()
  })

  it('shows a generic error on network failure (never throws)', async () => {
    const mockFetch = vi.fn().mockRejectedValue(new Error('Network error'))
    vi.stubGlobal('fetch', mockFetch)

    const hooks = makeHooks()
    const form = renderNewWorktreeForm(makeDetail(), hooks)
    ;(form.querySelector('input') as HTMLInputElement).value = 'feat'
    ;(form.querySelector('button') as HTMLButtonElement).click()

    await Promise.resolve()
    await Promise.resolve()
    await new Promise((r) => setTimeout(r, 0))

    const errorEl = form.querySelector('.proj-wt-error') as HTMLElement
    expect(errorEl.textContent).toBeTruthy()
    expect(hooks.onOpenProject).not.toHaveBeenCalled()
  })

  it('SEC-H6: error message set via textContent — <script> appears as literal text', async () => {
    const xss = '<script>alert(1)</script>'
    const mockFetch = vi.fn().mockResolvedValue({
      ok: false,
      json: async () => ({ ok: false, error: xss }),
    })
    vi.stubGlobal('fetch', mockFetch)

    const form = renderNewWorktreeForm(makeDetail(), makeHooks())
    ;(form.querySelector('input') as HTMLInputElement).value = 'feat'
    ;(form.querySelector('button') as HTMLButtonElement).click()

    await Promise.resolve()
    await Promise.resolve()
    await new Promise((r) => setTimeout(r, 0))

    const errorEl = form.querySelector('.proj-wt-error') as HTMLElement
    // No <script> element in DOM — textContent was used
    expect(errorEl.querySelector('script')).toBeNull()
    expect(errorEl.textContent).toBe(xss)
  })

  it('hides the error element when the user edits the input field', () => {
    const form = renderNewWorktreeForm(makeDetail(), makeHooks())
    // Trigger a validation error first
    ;(form.querySelector('button') as HTMLButtonElement).click()
    const errorEl = form.querySelector('.proj-wt-error') as HTMLElement
    expect(errorEl.style.display).not.toBe('none')

    // Simulate typing in the input
    const input = form.querySelector('input') as HTMLInputElement
    input.value = 'f'
    input.dispatchEvent(new Event('input'))
    expect(errorEl.style.display).toBe('none')
  })
})

/* ── renderProjectDetail — B1: View Diff ──────────────────────────────────── */

describe('renderProjectDetail — B1 View Diff', () => {
  it('shows a "View Diff" toggle button for git repos', () => {
    const root = renderProjectDetail(makeDetail({ isGit: true }), makeHooks(), makeCbs())
    expect(root.querySelector('.proj-diff-toggle')).not.toBeNull()
  })

  it('does NOT show "View Diff" button for non-git directories', () => {
    const root = renderProjectDetail(
      makeDetail({ isGit: false, branch: undefined }),
      makeHooks(),
      makeCbs(),
    )
    expect(root.querySelector('.proj-diff-toggle')).toBeNull()
  })

  it('diff panel is hidden initially', () => {
    const root = renderProjectDetail(makeDetail({ isGit: true }), makeHooks(), makeCbs())
    const panel = root.querySelector('.proj-diff-panel') as HTMLElement
    expect(panel.style.display).toBe('none')
  })

  it('clicking "View Diff" calls mountDiffViewer with the project path', () => {
    const detail = makeDetail({ path: '/home/user/proj', isGit: true })
    const root = renderProjectDetail(detail, makeHooks(), makeCbs())
    ;(root.querySelector('.proj-diff-toggle') as HTMLButtonElement).click()

    expect(mockMountDiffViewer).toHaveBeenCalledWith(
      expect.any(HTMLElement),
      '/home/user/proj',
      expect.objectContaining({ onClose: expect.any(Function) }),
    )
  })

  it('passes the repo worktree branches + current branch as diff bases (FR-B1.9)', () => {
    const detail = makeDetail({
      path: '/home/user/proj',
      isGit: true,
      branch: 'main',
      worktrees: [
        { path: '/home/user/proj', branch: 'main', isMain: true, isCurrent: true },
        { path: '/home/user/proj-wt/feat', branch: 'feat', isMain: false, isCurrent: false },
      ],
    })
    const root = renderProjectDetail(detail, makeHooks(), makeCbs())
    ;(root.querySelector('.proj-diff-toggle') as HTMLButtonElement).click()

    expect(mockMountDiffViewer).toHaveBeenCalledWith(
      expect.any(HTMLElement),
      '/home/user/proj',
      expect.objectContaining({ bases: expect.arrayContaining(['main', 'feat']) }),
    )
    // Deduped: `main` appears in both the current branch and a worktree → once.
    const opts = mockMountDiffViewer.mock.calls[0]?.[2] as { bases: string[] }
    expect(opts.bases.filter((b) => b === 'main')).toHaveLength(1)
  })

  it('clicking "View Diff" shows the diff panel', () => {
    const root = renderProjectDetail(makeDetail({ isGit: true }), makeHooks(), makeCbs())
    const panel = root.querySelector('.proj-diff-panel') as HTMLElement
    ;(root.querySelector('.proj-diff-toggle') as HTMLButtonElement).click()
    expect(panel.style.display).not.toBe('none')
  })

  it('the onClose callback passed to mountDiffViewer hides the panel', () => {
    const root = renderProjectDetail(makeDetail({ isGit: true }), makeHooks(), makeCbs())
    ;(root.querySelector('.proj-diff-toggle') as HTMLButtonElement).click()

    const opts = mockMountDiffViewer.mock.calls[0]?.[2] as { onClose?: () => void }
    opts?.onClose?.()

    const panel = root.querySelector('.proj-diff-panel') as HTMLElement
    expect(panel.style.display).toBe('none')
  })

  it('clicking the toggle again closes the diff panel and calls destroy()', () => {
    const root = renderProjectDetail(makeDetail({ isGit: true }), makeHooks(), makeCbs())
    const toggle = root.querySelector('.proj-diff-toggle') as HTMLButtonElement
    toggle.click() // open
    toggle.click() // close
    expect(mockDiffHandle.destroy).toHaveBeenCalled()
    const panel = root.querySelector('.proj-diff-panel') as HTMLElement
    expect(panel.style.display).toBe('none')
  })
})

/* ── renderProjectDetail — B3: New Worktree Form ──────────────────────────── */

describe('renderProjectDetail — B3 New Worktree Form', () => {
  it('includes a worktree form for git repos', () => {
    const root = renderProjectDetail(makeDetail({ isGit: true }), makeHooks(), makeCbs())
    expect(root.querySelector('.proj-wt-form')).not.toBeNull()
  })

  it('does NOT include a worktree form for non-git directories', () => {
    const root = renderProjectDetail(
      makeDetail({ isGit: false, branch: undefined }),
      makeHooks(),
      makeCbs(),
    )
    expect(root.querySelector('.proj-wt-form')).toBeNull()
  })
})

/* ── renderFanoutForm (W5 fan-out board) ──────────────────────────────────── */

describe('renderFanoutForm', () => {
  it('renders a prompt textarea, lane stepper, branch input, mode select, submit', () => {
    const form = renderFanoutForm(makeDetail(), makeHooks())
    expect(form.querySelector('textarea.proj-fanout-prompt')).not.toBeNull()
    const lanes = form.querySelector('input.proj-fanout-lanes') as HTMLInputElement
    expect(lanes).not.toBeNull()
    expect(lanes.min).toBe('2')
    expect(lanes.max).toBe('6') // default maxFanoutLanes
    expect(form.querySelector('input.proj-fanout-branch')).not.toBeNull()
    expect(form.querySelector('select.proj-fanout-mode')).not.toBeNull()
    expect(form.querySelector('button.proj-fanout-submit')).not.toBeNull()
  })

  it('honors a server-controlled max lane count', () => {
    const form = renderFanoutForm(makeDetail(), makeHooks(), 3)
    expect((form.querySelector('input.proj-fanout-lanes') as HTMLInputElement).max).toBe('3')
  })

  it('disables submit until a prompt is entered', () => {
    const form = renderFanoutForm(makeDetail(), makeHooks())
    const submit = form.querySelector('button.proj-fanout-submit') as HTMLButtonElement
    expect(submit.disabled).toBe(true)
    const prompt = form.querySelector('textarea.proj-fanout-prompt') as HTMLTextAreaElement
    prompt.value = 'add dark mode'
    prompt.dispatchEvent(new Event('input', { bubbles: true }))
    expect(submit.disabled).toBe(false)
  })

  it('auto-fills the branch base from the prompt slug until the user edits it', () => {
    const form = renderFanoutForm(makeDetail(), makeHooks())
    const prompt = form.querySelector('textarea.proj-fanout-prompt') as HTMLTextAreaElement
    const branch = form.querySelector('input.proj-fanout-branch') as HTMLInputElement
    prompt.value = 'Add Dark Mode!'
    prompt.dispatchEvent(new Event('input', { bubbles: true }))
    expect(branch.value).toBe('add-dark-mode')
    branch.value = 'custom-base'
    branch.dispatchEvent(new Event('input', { bubbles: true }))
    prompt.value = 'Add Dark Mode changed'
    prompt.dispatchEvent(new Event('input', { bubbles: true }))
    expect(branch.value).toBe('custom-base') // not clobbered after manual edit
  })

  it('submitting calls onFanout with the repo path, name, and options', () => {
    const hooks = makeHooks()
    const detail = makeDetail({ path: '/home/user/my-repo', name: 'my-repo' })
    const form = renderFanoutForm(detail, hooks)
    const prompt = form.querySelector('textarea.proj-fanout-prompt') as HTMLTextAreaElement
    const lanes = form.querySelector('input.proj-fanout-lanes') as HTMLInputElement
    const mode = form.querySelector('select.proj-fanout-mode') as HTMLSelectElement
    prompt.value = 'add dark mode'
    prompt.dispatchEvent(new Event('input', { bubbles: true }))
    lanes.value = '4'
    mode.value = 'plan'
    ;(form.querySelector('button.proj-fanout-submit') as HTMLButtonElement).click()
    expect(hooks.onFanout).toHaveBeenCalledWith('/home/user/my-repo', 'my-repo', {
      prompt: 'add dark mode',
      lanes: 4,
      branchBase: 'add-dark-mode',
      mode: 'plan',
    })
  })

  it('shows an inline error (textContent) and does not call onFanout with no prompt', () => {
    const hooks = makeHooks()
    const form = renderFanoutForm(makeDetail(), hooks)
    const submit = form.querySelector('button.proj-fanout-submit') as HTMLButtonElement
    submit.disabled = false // exercise the submit-handler guard directly
    submit.click()
    const err = form.querySelector('.proj-fanout-error') as HTMLElement
    expect(err.textContent).not.toBe('')
    expect(err.style.display).not.toBe('none')
    expect(hooks.onFanout).not.toHaveBeenCalled()
  })

  it('rejects an invalid branch base via inline error, not onFanout', () => {
    const hooks = makeHooks()
    const form = renderFanoutForm(makeDetail(), hooks)
    const prompt = form.querySelector('textarea.proj-fanout-prompt') as HTMLTextAreaElement
    const branch = form.querySelector('input.proj-fanout-branch') as HTMLInputElement
    prompt.value = 'ok prompt'
    prompt.dispatchEvent(new Event('input', { bubbles: true }))
    branch.value = '-bad' // leading dash → invalid git branch
    branch.dispatchEvent(new Event('input', { bubbles: true }))
    ;(form.querySelector('button.proj-fanout-submit') as HTMLButtonElement).click()
    expect(hooks.onFanout).not.toHaveBeenCalled()
    expect((form.querySelector('.proj-fanout-error') as HTMLElement).textContent).not.toBe('')
  })

  it('renders the Fan out section inside a git project detail', () => {
    const root = renderProjectDetail(makeDetail({ isGit: true }), makeHooks(), makeCbs())
    expect(root.querySelector('.proj-fanout-form')).not.toBeNull()
    expect(root.textContent).toContain('Fan out')
  })

  it('omits the Fan out form for a non-git directory', () => {
    const root = renderProjectDetail(
      makeDetail({ isGit: false, branch: undefined }),
      makeHooks(),
      makeCbs(),
    )
    expect(root.querySelector('.proj-fanout-form')).toBeNull()
  })
})

/* ── renderProjectDetail — A4: Activity section ───────────────────────────── */

describe('renderProjectDetail — A4 Activity section', () => {
  const runningSess = {
    id: 'sess-1',
    title: 'claude',
    status: 'working' as const,
    clientCount: 1,
    createdAt: 1,
    exited: false,
  }
  const exitedSess = {
    id: 'sess-2',
    title: 'shell',
    status: 'idle' as const,
    clientCount: 0,
    createdAt: 2,
    exited: true,
  }

  it('shows the activity section when at least one session is running', () => {
    const root = renderProjectDetail(
      makeDetail({ sessions: [runningSess] }),
      makeHooks(),
      makeCbs(),
    )
    expect(root.querySelector('.proj-activity-section')).not.toBeNull()
  })

  it('mounts a timeline for each running session', () => {
    renderProjectDetail(makeDetail({ sessions: [runningSess] }), makeHooks(), makeCbs())
    expect(mockMountTimeline).toHaveBeenCalledWith(expect.any(HTMLElement), 'sess-1')
  })

  it('does NOT mount timelines for exited sessions', () => {
    renderProjectDetail(makeDetail({ sessions: [exitedSess] }), makeHooks(), makeCbs())
    expect(mockMountTimeline).not.toHaveBeenCalled()
  })

  it('mounts one timeline per running session', () => {
    const sess2 = { id: 'sess-3', title: 'codex', status: 'working' as const, clientCount: 1, createdAt: 3, exited: false }
    renderProjectDetail(
      makeDetail({ sessions: [runningSess, sess2] }),
      makeHooks(),
      makeCbs(),
    )
    expect(mockMountTimeline).toHaveBeenCalledTimes(2)
    expect(mockMountTimeline).toHaveBeenCalledWith(expect.any(HTMLElement), 'sess-1')
    expect(mockMountTimeline).toHaveBeenCalledWith(expect.any(HTMLElement), 'sess-3')
  })

  it('disposes timeline handles when onBack is clicked', () => {
    const cb = makeCbs()
    const root = renderProjectDetail(makeDetail({ sessions: [runningSess] }), makeHooks(), cb)

    expect(mockTimelineHandle.dispose).not.toHaveBeenCalled()
    ;(root.querySelector('.proj-back') as HTMLButtonElement).click()
    expect(mockTimelineHandle.dispose).toHaveBeenCalled()
    expect(cb.onBack).toHaveBeenCalled()
  })

  it('hides the activity section when no sessions are running', () => {
    const root = renderProjectDetail(makeDetail({ sessions: [] }), makeHooks(), makeCbs())
    expect(root.querySelector('.proj-activity-section')).toBeNull()
    expect(mockMountTimeline).not.toHaveBeenCalled()
  })

  it('hides the activity section when all sessions are exited', () => {
    const root = renderProjectDetail(makeDetail({ sessions: [exitedSess] }), makeHooks(), makeCbs())
    expect(root.querySelector('.proj-activity-section')).toBeNull()
  })

  it('timeline collector receives handles when provided (re-render disposal)', () => {
    const collector: Array<{ dispose(): void }> = []
    renderProjectDetail(
      makeDetail({ sessions: [runningSess] }),
      makeHooks(),
      makeCbs(),
      collector,
    )
    expect(collector).toHaveLength(1)
    expect(collector[0]).toBe(mockTimelineHandle)
  })

  it('disposes the diff handle on back when the diff panel was open', () => {
    const root = renderProjectDetail(makeDetail({ isGit: true }), makeHooks(), makeCbs())
    // Open diff
    ;(root.querySelector('.proj-diff-toggle') as HTMLButtonElement).click()
    // Press back
    ;(root.querySelector('.proj-back') as HTMLButtonElement).click()
    expect(mockDiffHandle.destroy).toHaveBeenCalled()
  })
})

/* ── W4: makeWorktreeRow remove button ─────────────────────────────────────── */

function wt(overrides: Partial<WorktreeInfo> = {}): WorktreeInfo {
  return { path: '/repo/wt/feat', branch: 'feat', isMain: false, isCurrent: false, ...overrides }
}

describe('makeWorktreeRow — W4 remove button', () => {
  const actions = { onRemove: vi.fn() }

  it('adds a remove button for a non-main, non-locked worktree when actions are given', () => {
    const row = makeWorktreeRow(wt(), actions)
    expect(row.querySelector('.proj-wt-remove')).not.toBeNull()
  })

  it('omits the remove button when no actions are given (read-only row)', () => {
    const row = makeWorktreeRow(wt())
    expect(row.querySelector('.proj-wt-remove')).toBeNull()
  })

  it('never offers a remove button for the main worktree', () => {
    const row = makeWorktreeRow(wt({ isMain: true, branch: 'main' }), actions)
    expect(row.querySelector('.proj-wt-remove')).toBeNull()
    expect(row.textContent).toContain('main')
  })

  it('never offers a remove button for a locked worktree (keeps the locked tag)', () => {
    const row = makeWorktreeRow(wt({ locked: true }), actions)
    expect(row.querySelector('.proj-wt-remove')).toBeNull()
    expect(row.textContent).toContain('locked')
  })

  it('still offers a remove button for a prunable worktree', () => {
    const row = makeWorktreeRow(wt({ prunable: true }), actions)
    expect(row.querySelector('.proj-wt-remove')).not.toBeNull()
  })

  it('clicking the remove button calls onRemove with the worktree', () => {
    const onRemove = vi.fn()
    const w = wt()
    const row = makeWorktreeRow(w, { onRemove })
    ;(row.querySelector('.proj-wt-remove') as HTMLButtonElement).click()
    expect(onRemove).toHaveBeenCalledWith(w)
  })
})

/* ── W4: renderProjectDetail prune button + remove wiring ──────────────────── */

describe('renderProjectDetail — W4 prune + remove wiring', () => {
  const mainWt: WorktreeInfo = { path: '/repo', branch: 'main', isMain: true, isCurrent: true }
  const featWt: WorktreeInfo = { path: '/repo/wt/feat', branch: 'feat', isMain: false, isCurrent: false }
  const staleWt: WorktreeInfo = {
    path: '/repo/wt/gone', branch: 'gone', isMain: false, isCurrent: false, prunable: true,
  }

  it('shows a "Prune stale worktrees" button when a worktree is prunable and a callback is wired', () => {
    const root = renderProjectDetail(
      makeDetail({ worktrees: [mainWt, staleWt] }),
      makeHooks(),
      { ...makeCbs(), onPruneWorktrees: vi.fn() },
    )
    expect(root.querySelector('.proj-wt-prune')).not.toBeNull()
  })

  it('hides the prune button when no worktree is prunable', () => {
    const root = renderProjectDetail(
      makeDetail({ worktrees: [mainWt, featWt] }),
      makeHooks(),
      { ...makeCbs(), onPruneWorktrees: vi.fn() },
    )
    expect(root.querySelector('.proj-wt-prune')).toBeNull()
  })

  it('hides the prune button when no prune callback is wired (pre-W4 callers)', () => {
    const root = renderProjectDetail(makeDetail({ worktrees: [mainWt, staleWt] }), makeHooks(), makeCbs())
    expect(root.querySelector('.proj-wt-prune')).toBeNull()
  })

  it('threads onRemoveWorktree so clicking a row remove button passes the worktree path', () => {
    const onRemoveWorktree = vi.fn()
    const root = renderProjectDetail(
      makeDetail({ worktrees: [mainWt, featWt] }),
      makeHooks(),
      { ...makeCbs(), onRemoveWorktree },
    )
    const btn = root.querySelector('.proj-wt-remove') as HTMLButtonElement
    expect(btn).not.toBeNull()
    btn.click()
    expect(onRemoveWorktree).toHaveBeenCalledWith('/repo/wt/feat')
  })

  it('clicking prune invokes onPruneWorktrees', () => {
    const onPruneWorktrees = vi.fn()
    const root = renderProjectDetail(
      makeDetail({ worktrees: [mainWt, staleWt] }),
      makeHooks(),
      { ...makeCbs(), onPruneWorktrees },
    )
    ;(root.querySelector('.proj-wt-prune') as HTMLButtonElement).click()
    expect(onPruneWorktrees).toHaveBeenCalled()
  })
})

/* ── W4: confirmAndRemoveWorktree / confirmAndPruneWorktrees ───────────────── */

describe('confirmAndRemoveWorktree — confirm → (force) → refresh flow', () => {
  it('confirms, DELETEs with force:false, and returns "removed" on success', async () => {
    vi.spyOn(window, 'confirm').mockReturnValue(true)
    const mockFetch = vi.fn().mockResolvedValue({ ok: true, status: 200, json: async () => ({ ok: true }) })
    vi.stubGlobal('fetch', mockFetch)

    const outcome = await confirmAndRemoveWorktree('/repo', '/repo/wt/feat', vi.fn())
    expect(outcome).toBe('removed')
    expect(mockFetch).toHaveBeenCalledWith(
      '/projects/worktree',
      expect.objectContaining({
        method: 'DELETE',
        body: JSON.stringify({ path: '/repo', worktreePath: '/repo/wt/feat', force: false }),
      }),
    )
  })

  it('on a 409 dirty refusal, asks a SECOND confirm then retries with force:true', async () => {
    vi.spyOn(window, 'confirm').mockReturnValue(true)
    const mockFetch = vi
      .fn()
      .mockResolvedValueOnce({ ok: false, status: 409, json: async () => ({ ok: false, error: 'dirty' }) })
      .mockResolvedValueOnce({ ok: true, status: 200, json: async () => ({ ok: true }) })
    vi.stubGlobal('fetch', mockFetch)

    const outcome = await confirmAndRemoveWorktree('/repo', '/repo/wt/feat', vi.fn())
    expect(outcome).toBe('removed')
    expect(mockFetch).toHaveBeenCalledTimes(2)
    // Second call carries force:true.
    expect(mockFetch.mock.calls[1]?.[1]).toMatchObject({
      body: JSON.stringify({ path: '/repo', worktreePath: '/repo/wt/feat', force: true }),
    })
  })

  it('does NOT fetch when the user cancels the first confirm (no accidental delete)', async () => {
    vi.spyOn(window, 'confirm').mockReturnValue(false)
    const mockFetch = vi.fn()
    vi.stubGlobal('fetch', mockFetch)

    const outcome = await confirmAndRemoveWorktree('/repo', '/repo/wt/feat', vi.fn())
    expect(outcome).toBe('cancelled')
    expect(mockFetch).not.toHaveBeenCalled()
  })

  it('does NOT force-delete when the user cancels the second (force) confirm', async () => {
    vi.spyOn(window, 'confirm').mockReturnValueOnce(true).mockReturnValueOnce(false)
    const mockFetch = vi
      .fn()
      .mockResolvedValueOnce({ ok: false, status: 409, json: async () => ({ ok: false, error: 'dirty' }) })
    vi.stubGlobal('fetch', mockFetch)

    const outcome = await confirmAndRemoveWorktree('/repo', '/repo/wt/feat', vi.fn())
    expect(outcome).toBe('cancelled')
    expect(mockFetch).toHaveBeenCalledTimes(1) // only the non-force attempt
  })

  it('reports a safe error via the onError callback and renders it via textContent (SEC-L3/H6)', async () => {
    vi.spyOn(window, 'confirm').mockReturnValue(true)
    const mockFetch = vi
      .fn()
      .mockResolvedValue({ ok: false, status: 500, json: async () => ({ ok: false, error: 'boom' }) })
    vi.stubGlobal('fetch', mockFetch)

    const errEl = document.createElement('div')
    const onError = (msg: string): void => { errEl.textContent = msg }
    const outcome = await confirmAndRemoveWorktree('/repo', '/repo/wt/feat', onError)

    expect(outcome).toBe('error')
    expect(errEl.textContent).toBe('boom')
    expect(errEl.querySelector('script')).toBeNull() // textContent, never innerHTML
  })

  it('reports a safe error on network failure (never throws)', async () => {
    vi.spyOn(window, 'confirm').mockReturnValue(true)
    vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('offline')))
    const onError = vi.fn()
    const outcome = await confirmAndRemoveWorktree('/repo', '/repo/wt/feat', onError)
    expect(outcome).toBe('error')
    expect(onError).toHaveBeenCalled()
  })
})

describe('confirmAndPruneWorktrees', () => {
  it('confirms, POSTs to the prune route, and returns "pruned" on success', async () => {
    vi.spyOn(window, 'confirm').mockReturnValue(true)
    const mockFetch = vi
      .fn()
      .mockResolvedValue({ ok: true, status: 200, json: async () => ({ ok: true, pruned: [] }) })
    vi.stubGlobal('fetch', mockFetch)

    const outcome = await confirmAndPruneWorktrees('/repo', vi.fn())
    expect(outcome).toBe('pruned')
    expect(mockFetch).toHaveBeenCalledWith(
      '/projects/worktree/prune',
      expect.objectContaining({ method: 'POST', body: JSON.stringify({ path: '/repo' }) }),
    )
  })

  it('does NOT fetch when the user cancels', async () => {
    vi.spyOn(window, 'confirm').mockReturnValue(false)
    const mockFetch = vi.fn()
    vi.stubGlobal('fetch', mockFetch)
    const outcome = await confirmAndPruneWorktrees('/repo', vi.fn())
    expect(outcome).toBe('cancelled')
    expect(mockFetch).not.toHaveBeenCalled()
  })

  it('reports a safe error via onError on failure', async () => {
    vi.spyOn(window, 'confirm').mockReturnValue(true)
    vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
      ok: false, status: 500, json: async () => ({ ok: false, error: 'nope' }),
    }))
    const onError = vi.fn()
    const outcome = await confirmAndPruneWorktrees('/repo', onError)
    expect(outcome).toBe('error')
    expect(onError).toHaveBeenCalledWith('nope')
  })
})
