import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react'
import '@testing-library/jest-dom/vitest'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { SESSIONS_PANEL_DATA, SESSION_FIXTURES } from '../../../tests/fixtures/sessions-fixtures'
import { fetchCollectorState, fetchSessionScreen } from '../../lib/collector-client'
import { COLLECTOR_POLL_MS } from '../../lib/collector-queries'
import type { StateResponse } from '../../lib/collector-types'
import type { SessionsPanelData, SessionView } from '../../lib/session-types'
import { SessionsContent } from './SessionsContent'
import { SessionTerminalModal } from './SessionTerminalModal'

vi.mock('../../lib/collector-client', async () => {
  const actual = await vi.importActual<typeof import('../../lib/collector-client')>('../../lib/collector-client')
  return { ...actual, fetchCollectorState: vi.fn(), fetchSessionScreen: vi.fn() }
})

function stateResponse(data: Partial<SessionsPanelData> = {}): StateResponse {
  const panel: SessionsPanelData = { ...SESSIONS_PANEL_DATA, sessions: [], ...data }
  return { panels: [{ id: 'sessions', ts: '2026-08-07T02:00:00.000Z', data: panel }], adapters: [] }
}

function escapeRegExp(value: string): string {
  return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
}

function projectSection(summary: string): HTMLElement {
  const heading = screen.getByRole('heading', {
    name: new RegExp(`^${escapeRegExp(summary)} ·`),
  })
  return heading.closest('div') as HTMLElement
}

function expandProject(summary: string): HTMLElement {
  const section = projectSection(summary)
  const button = within(section).getByRole('button', {
    name: new RegExp(`^${escapeRegExp(summary)} ·`),
  })
  if (button.getAttribute('aria-expanded') === 'false') fireEvent.click(button)
  return section
}

// Column order in SessionsContent: session, cli, who, host, branch, state, running.
const RUNNING_COLUMN = 6

/** Ten runs in one project — one live, titled so it sorts last — to exercise truncation. */
function crowdedProject(): SessionView[] {
  const base = SESSION_FIXTURES[0]!
  return Array.from({ length: 10 }, (_unused, index) => ({
    ...base,
    id: `crowded-${index}`,
    title: index === 0 ? 'crowded live' : `crowded a${index}`,
    cwd: '/home/user/Projects/crowded',
    repoRoot: '/home/user/Projects/crowded',
    project: '/home/user/Projects/crowded',
    state: index === 0 ? ('ALIVE-WORKING' as const) : ('FINISHED' as const),
    finishedAt: index === 0 ? null : '2026-08-07T01:00:00.000Z',
  }))
}

function renderContent() {
  const client = new QueryClient({ defaultOptions: { queries: { retry: false } } })
  return render(
    <QueryClientProvider client={client}>
      <SessionsContent />
      <SessionTerminalModal />
    </QueryClientProvider>,
  )
}

async function renderFixtures() {
  vi.mocked(fetchCollectorState).mockResolvedValue(stateResponse({ sessions: SESSION_FIXTURES }))
  renderContent()
  await waitFor(() => expect(screen.getByText(/wire the observability panel/)).toBeInTheDocument())
}

beforeEach(() => {
  vi.mocked(fetchCollectorState).mockReset()
  vi.mocked(fetchSessionScreen).mockResolvedValue({
    ok: true,
    screen: '',
    capturedAt: '2026-08-07T02:00:00.000Z',
  })
})

afterEach(() => { cleanup(); window.history.replaceState({}, '', '/sessions') })

describe('SessionsContent', () => {
  it('hydrates exact session selection from URL and follows history changes', async () => {
    const first = SESSION_FIXTURES[0]!
    const second = SESSION_FIXTURES[1]!
    window.history.replaceState({}, '', `/sessions?session=${encodeURIComponent(first.id)}`)
    await renderFixtures()
    await waitFor(() => expect(screen.getByRole('dialog')).toHaveTextContent(first.id))

    window.history.pushState({}, '', `/sessions?session=${encodeURIComponent(second.id)}`)
    window.dispatchEvent(new PopStateEvent('popstate'))
    await waitFor(() => expect(screen.getByRole('dialog')).toHaveTextContent(second.id))

    fireEvent.click(screen.getByRole('button', { name: 'Close' }))
    expect(new URL(window.location.href).searchParams.has('session')).toBe(false)
  })

  it('shows an honest empty state naming the ledger directory', async () => {
    vi.mocked(fetchCollectorState).mockResolvedValue(stateResponse({ ledgerMissing: true }))
    renderContent()
    await waitFor(() => expect(screen.getByText('No sessions recorded')).toBeInTheDocument())
    expect(screen.getAllByText(/\/home\/user\/\.local\/state\/agent-sessions/).length).toBeGreaterThan(0)
    expect(screen.getByText(/does not exist yet/)).toBeInTheDocument()
  })

  it('renders every project the ledger knows, not just the current one', async () => {
    await renderFixtures()
    for (const label of [
      'overdeck · obs-panel',
      'Projects/multideal',
      'Projects/invariantum',
      'Projects/security-gate',
      'Projects/virtuac',
      'Projects/harnessd',
      'Projects/scratch',
      'builds/overdeck',
      'home directory — not a project',
    ]) {
      const section = expandProject(label)
      expect(within(section).getByRole('table', { name: `Sessions in ${label}` })).toBeInTheDocument()
    }
  })

  it('starts an all-finished project collapsed with only its summary visible', async () => {
    await renderFixtures()
    const section = projectSection('Projects/security-gate')
    expect(screen.getByRole('heading', { name: /Projects\/security-gate · 1 session/ })).toBeInTheDocument()
    expect(within(section).queryByRole('table', { name: 'Sessions in Projects/security-gate' })).toBeNull()
  })

  it('reveals an all-finished project table when its header is clicked', async () => {
    await renderFixtures()
    const section = expandProject('Projects/security-gate')
    await waitFor(() =>
      expect(within(section).getByRole('table', { name: 'Sessions in Projects/security-gate' })).toBeInTheDocument(),
    )
  })

  it('gives each row a real title and falls back to a named gap, never to the id', async () => {
    await renderFixtures()
    expect(screen.getByText('close the ledger enrollment gap')).toBeInTheDocument()
    expect(screen.getAllByText('no title recorded').length).toBeGreaterThan(0)
  })

  it('shows each cli and each launcher distinctly', async () => {
    await renderFixtures()
    expandProject('Projects/invariantum')
    expect(screen.getAllByText('codex').length).toBeGreaterThan(0)
    expect(screen.getAllByText('cursor-agent').length).toBeGreaterThan(0)
    expect(screen.getAllByText('factory').length).toBeGreaterThan(0)
    expect(screen.getAllByText('agent').length).toBeGreaterThan(0)
    expect(screen.getAllByText('user').length).toBeGreaterThan(0)
  })

  it('says whether a window is attached to the session', async () => {
    await renderFixtures()
    expect(screen.getAllByText('window attached').length).toBeGreaterThan(0)
    expect(screen.getAllByText('no window attached').length).toBeGreaterThan(0)
    expect(screen.getAllByText('window unknown').length).toBeGreaterThan(0)
  })

  it('offers a reconnect action per reachable row and a reason instead of a dead button', async () => {
    await renderFixtures()
    expandProject('Projects/invariantum')
    expandProject('Projects/security-gate')
    expandProject('Projects/virtuac')
    expandProject('builds/overdeck')
    expect(screen.getAllByRole('button', { name: 'Attach' })).toHaveLength(1)
    expect(screen.getAllByRole('button', { name: 'Reopen' }).length).toBeGreaterThan(0)
    expect(screen.getAllByRole('button', { name: 'Resume' }).length).toBeGreaterThan(0)
    expect(screen.getAllByText(/no session id was recorded/).length).toBeGreaterThan(0)
    expect(within(expandProject('builds/overdeck')).getByText(/host debian3 is disabled in the buildbox registry/)).toBeInTheDocument()
  })

  it('never renders a negative age for a session whose clock is wrong', async () => {
    await renderFixtures()
    const section = expandProject('Projects/harnessd')
    const table = within(section).getByRole('table', { name: 'Sessions in Projects/harnessd' })
    const row = within(table).getByText('entry written with a local time labelled Z').closest('tr')
    expect(row).not.toBeNull()
    expect(within(row!).queryByText(/^-/)).toBeNull()
    expect(within(row!).getAllByText('—').length).toBeGreaterThan(0)
  })

  it('freezes the running time of a session that is no longer running', async () => {
    const runningCell = async (nowIso: string): Promise<string> => {
      const clock = vi.spyOn(Date, 'now').mockReturnValue(Date.parse(nowIso))
      await renderFixtures()
      const section = expandProject('Projects/invariantum')
      const cells = within(section).getByText('restyle the limits page').closest('tr')!.querySelectorAll('td')
      const value = cells[RUNNING_COLUMN]!.textContent ?? ''
      clock.mockRestore()
      cleanup()
      return value
    }
    const frozen = await runningCell('2026-08-09T00:00:00.000Z')
    expect(frozen).toMatch(/^\d/)
    expect(frozen).toBe(await runningCell('2026-08-07T03:00:00.000Z'))
  })

  it('names each field the ledger does not carry yet rather than leaving a blank column', async () => {
    await renderFixtures()
    expect(screen.getByText(/without a title/)).toBeInTheDocument()
    expect(screen.getByText(/without a launcher/)).toBeInTheDocument()
    expect(screen.getByText(/without a current activity/)).toBeInTheDocument()
  })

  it('says why a session that recorded nothing ended, instead of showing an empty row', async () => {
    await renderFixtures()
    const section = expandProject('Projects/harnessd')
    const row = within(section).getByText('sess-nothing-recorded').closest('tr')!
    fireEvent.click(within(row).getByRole('button', { name: 'no title recorded' }))
    await waitFor(() => expect(screen.getByText('Ended because')).toBeInTheDocument())
    expect(screen.getByText('process exited, nothing was recorded to recover')).toBeInTheDocument()
  })

  it('warns that a long tool call reads as idle rather than letting him misread the column', async () => {
    await renderFixtures()
    expect(screen.getByText(/writes nothing and reads idle while it is still working/)).toBeInTheDocument()
  })

  it('states that a session outside the ledger is invisible to the page', async () => {
    await renderFixtures()
    expect(screen.getByText(/started outside the recorder is invisible to this page/)).toBeInTheDocument()
  })

  it('has no column for uncommitted files — it was on every row and actionable on none', async () => {
    await renderFixtures()
    expect(screen.queryByText('Uncommitted')).toBeNull()
  })

  it('says a stopped session RAN for its length and a live one is still RUNNING', async () => {
    await renderFixtures()
    const section = expandProject('Projects/invariantum')
    const row = within(section).getByText('restyle the limits page').closest('tr')
    fireEvent.click(within(row!).getByRole('button', { name: 'restyle the limits page' }))
    await waitFor(() => expect(screen.getByText('Ran for')).toBeInTheDocument())
    expect(screen.queryByText('Running for')).toBeNull()

    fireEvent.click(screen.getByRole('button', { name: 'wire the observability panel' }))
    await waitFor(() => expect(screen.getByText('Running for')).toBeInTheDocument())
    expect(screen.queryByText('Ran for')).toBeNull()
  })

  it('keeps an open terminal in step with the ledger instead of freezing the row it opened on', async () => {
    vi.useFakeTimers({ shouldAdvanceTime: true })
    try {
      const live = SESSION_FIXTURES.find((session) => session.title === 'wire the observability panel')!
      vi.mocked(fetchCollectorState).mockResolvedValue(stateResponse({ sessions: [live] }))
      renderContent()
      await waitFor(() => expect(screen.getByRole('button', { name: 'wire the observability panel' })).toBeInTheDocument())

      fireEvent.click(screen.getByRole('button', { name: 'wire the observability panel' }))
      await waitFor(() => expect(screen.getByText('Running for')).toBeInTheDocument())

      vi.mocked(fetchCollectorState).mockResolvedValue(stateResponse({
        sessions: [{ ...live, state: 'FINISHED' as const, finishedAt: '2026-08-07T02:30:00.000Z' }],
      }))
      await vi.advanceTimersByTimeAsync(COLLECTOR_POLL_MS + 1_000)

      await waitFor(() => expect(screen.getByText('Ran for')).toBeInTheDocument())
      expect(screen.queryByText('Running for')).toBeNull()
    } finally {
      vi.useRealTimers()
    }
  })

  it('keeps the present tense for a live session whose run length cannot be measured', async () => {
    await renderFixtures()
    fireEvent.click(screen.getByRole('button', { name: 'entry written with a local time labelled Z' }))
    await waitFor(() => expect(screen.getByText('Running for')).toBeInTheDocument())
    expect(screen.queryByText('Ran for')).toBeNull()
  })

  it('shows five runs per project and keeps the rest reachable behind an honest count', async () => {
    vi.mocked(fetchCollectorState).mockResolvedValue(stateResponse({ sessions: crowdedProject() }))
    renderContent()
    await waitFor(() => expect(screen.getByRole('heading', { name: /^Projects\/crowded ·/ })).toBeInTheDocument())

    const section = expandProject('Projects/crowded')
    const table = () => within(section).getByRole('table', { name: 'Sessions in Projects/crowded' })
    expect(within(table()).getAllByRole('row')).toHaveLength(6) // header + 5
    const more = within(section).getByRole('button', { name: '5 more' })

    fireEvent.click(more)
    await waitFor(() => expect(within(table()).getAllByRole('row')).toHaveLength(11))
    expect(within(section).getByRole('button', { name: 'Show 5 of 10' })).toBeInTheDocument()
  })

  it('never truncates a running session away, whatever column the reader sorts by', async () => {
    vi.mocked(fetchCollectorState).mockResolvedValue(stateResponse({ sessions: crowdedProject() }))
    renderContent()
    await waitFor(() => expect(screen.getByRole('heading', { name: /^Projects\/crowded ·/ })).toBeInTheDocument())

    const section = expandProject('Projects/crowded')
    const projectTable = () => within(section).getByRole('table', { name: 'Sessions in Projects/crowded' })

    // "crowded live" sorts last of the ten by title, so an ascending sort puts it outside
    // the visible five unless live-first is the primary key rather than the initial order.
    fireEvent.click(within(projectTable()).getByRole('button', { name: 'Session' }))
    await waitFor(() => expect(within(projectTable()).getByText('crowded live')).toBeInTheDocument())
    expect(within(projectTable()).getAllByRole('row')).toHaveLength(6)
  })

  it('states why attach is off when the collector is not on loopback', async () => {
    vi.mocked(fetchCollectorState).mockResolvedValue(stateResponse({
      sessions: SESSION_FIXTURES,
      attachEnabled: false,
      attachBlockedReason: 'collector is bound to 100.64.0.2, not loopback',
    }))
    renderContent()
    await waitFor(() => expect(screen.getByText(/not loopback/)).toBeInTheDocument())
  })
})
