import { describe, expect, it } from 'vitest'
import { SESSION_FIXTURES } from '../../tests/fixtures/sessions-fixtures'
import {
  durationSince,
  groupSessionsByProject,
  idleTime,
  projectLabel,
  runningTime,
  sessionStateChip,
  toAgentSessionRow,
  type SessionView,
} from './session-types'

function row(id: string) {
  const source = SESSION_FIXTURES.find((session) => session.id === id)
  if (!source) throw new Error(`fixture ${id} is missing`)
  return toAgentSessionRow(source)
}

function view(overrides: Partial<SessionView> = {}): SessionView {
  return { ...SESSION_FIXTURES[0]!, ...overrides }
}

describe('session state chips', () => {
  it('keeps detached-alive visually distinct from orphaned', () => {
    expect(sessionStateChip('DETACHED-ALIVE').status).not.toBe(sessionStateChip('ORPHANED').status)
    expect(sessionStateChip('ORPHANED').status).toBe('failed')
    expect(sessionStateChip('DETACHED-ALIVE').label).toContain('reattachable')
  })

  it('separates working from idle', () => {
    expect(sessionStateChip('ALIVE-WORKING').status).toBe('running')
    expect(sessionStateChip('ALIVE-IDLE').status).toBe('queued')
  })
})

describe('toAgentSessionRow', () => {
  it('maps the ledger vocabulary onto the pinned states without losing the detail one', () => {
    expect(row('sess-tmux-live').state).toBe('live')
    expect(row('sess-dtach-detached').state).toBe('live')
    expect(row('sess-dtach-detached').ledgerState).toBe('DETACHED-ALIVE')
    expect(row('sess-codex-idle').state).toBe('idle')
    expect(row('sess-cursor-finished').state).toBe('finished')
    expect(row('sess-orphaned-noresume').state).toBe('unknown')
  })

  it('keeps each cli distinct rather than flattening them to one runtime', () => {
    expect(new Set(SESSION_FIXTURES.map((session) => toAgentSessionRow(session).cli)))
      .toEqual(new Set(['claude', 'codex', 'cursor-agent']))
  })

  it('reports every absent field as null instead of a plausible substitute', () => {
    const bare = row('sess-bare')
    expect(bare.title).toBeNull()
    expect(bare.activity).toBeNull()
    expect(bare.launchedBy).toBeNull()
    expect(bare.host).toBeNull()
    expect(bare.branch).toBeNull()
    expect(bare.startedAt).toBeNull()
    expect(bare.lastActiveAt).toBeNull()
    expect(bare.attached).toBeNull()
  })

  it('never back-fills last activity from the session start time', () => {
    const noProgress = toAgentSessionRow(view({ lastActiveAt: null, lastProgressAt: null, lastHeartbeatAt: null, startedAt: '2026-08-07T00:00:00.000Z' }))
    expect(noProgress.lastActiveAt).toBeNull()
    expect(noProgress.startedAt).toBe('2026-08-07T00:00:00.000Z')
  })

  it('infers launchedBy only from a recorded parent, never from anything else', () => {
    expect(toAgentSessionRow(view({ launchedBy: null, parentLedgerId: 'claude-parent' })).launchedBy).toBe('agent')
    expect(toAgentSessionRow(view({ launchedBy: null, parentLedgerId: null })).launchedBy).toBeNull()
    expect(toAgentSessionRow(view({ launchedBy: 'factory' })).launchedBy).toBe('factory')
  })

  it('refuses a launcher value outside the contract rather than showing it as fact', () => {
    expect(toAgentSessionRow(view({ launchedBy: 'cron', parentLedgerId: null })).launchedBy).toBeNull()
  })

  it('flags a resume command that carries no account', () => {
    expect(row('sess-claude-finished').accountKnown).toBe(true)
    expect(row('sess-cursor-finished').accountKnown).toBe(false)
  })

  it('carries the reconnect descriptor through untouched, reason included', () => {
    expect(row('sess-tmux-live').attach.kind).toBe('tmux')
    expect(row('sess-remote-debian1').attach.kind).toBe('ssh')
    expect(row('sess-claude-finished').attach.command).toContain('--account zync2')
    expect(row('sess-orphaned-noresume').attach.kind).toBe('none')
    expect(row('sess-orphaned-noresume').attach.reason).toContain('no session id was recorded')
  })

  it('covers every attach kind in the contract', () => {
    expect(new Set(SESSION_FIXTURES.map((session) => toAgentSessionRow(session).attach.kind)))
      .toEqual(new Set(['tmux', 'resume', 'ssh', 'none']))
  })

  it('gives every unreachable row a reason', () => {
    for (const session of SESSION_FIXTURES) {
      const mapped = toAgentSessionRow(session)
      if (mapped.attach.kind === 'none') expect(mapped.attach.reason).toBeTruthy()
      else expect(mapped.attach.command).toBeTruthy()
    }
  })
})

describe('durationSince', () => {
  const now = Date.parse('2026-08-07T03:00:00.000Z')

  it('measures a real elapsed time', () => {
    const derived = durationSince('2026-08-07T02:00:00.000Z', now)
    expect(derived).toEqual({ known: true, ms: 3_600_000, sinceIso: '2026-08-07T02:00:00.000Z' })
  })

  it('reports an absent timestamp as unknown with no invented problem', () => {
    expect(durationSince(null, now)).toEqual({ known: false, problem: null })
  })

  it('never returns a negative duration for a future timestamp — it names the data bug', () => {
    const derived = durationSince('2099-01-01T00:00:00.000Z', now)
    expect(derived.known).toBe(false)
    expect(derived.known === false && derived.problem).toContain('bad clock value')
  })

  it('names an unparseable timestamp instead of rendering NaN', () => {
    const derived = durationSince('not a date', now)
    expect(derived.known).toBe(false)
    expect(derived.known === false && derived.problem).toContain('unparseable')
  })
})

describe('groupSessionsByProject', () => {
  it('groups every project, live groups first', () => {
    const groups = groupSessionsByProject(SESSION_FIXTURES.map(toAgentSessionRow))
    expect(groups.length).toBeGreaterThan(4)
    const firstDead = groups.findIndex((group) => group.liveCount === 0)
    const lastLive = groups.map((group) => group.liveCount > 0).lastIndexOf(true)
    expect(firstDead === -1 || firstDead > lastLive).toBe(true)
  })

  it('sorts live rows above finished ones inside a group', () => {
    const rows = [
      toAgentSessionRow(view({ id: 'done', state: 'FINISHED', project: '/p', lastProgressAt: '2026-08-07T09:00:00.000Z' })),
      toAgentSessionRow(view({ id: 'live', state: 'ALIVE-WORKING', project: '/p', lastProgressAt: '2026-08-07T01:00:00.000Z' })),
    ]
    expect(groupSessionsByProject(rows)[0]!.rows.map((entry) => entry.id)).toEqual(['live', 'done'])
  })

  it('sorts rows with no timestamps last instead of first', () => {
    const rows = [
      toAgentSessionRow(view({ id: 'unknown', project: '/p', startedAt: null, lastHeartbeatAt: null, lastProgressAt: null, lastActiveAt: null })),
      toAgentSessionRow(view({ id: 'known', project: '/p' })),
    ]
    expect(groupSessionsByProject(rows)[0]!.rows.map((entry) => entry.id)).toEqual(['known', 'unknown'])
  })

  it('returns nothing for an empty ledger', () => {
    expect(groupSessionsByProject([])).toEqual([])
  })
})

describe('projectLabel', () => {
  it('names a worktree by its repo and slug so two worktrees never read alike', () => {
    expect(projectLabel('/home/user/Projects/overdeck/.worktrees/obs-panel')).toBe('overdeck · obs-panel')
  })

  it('keeps a checkout and a build clone of one repo apart', () => {
    expect(projectLabel('/home/user/Projects/overdeck')).not.toBe(projectLabel('/home/user/builds/overdeck'))
  })

  it('strips the home prefix instead of printing it on every group', () => {
    expect(projectLabel('/home/user/Projects/overdeck', '/home/user')).toBe('Projects/overdeck')
  })

  it('never names a group after the user, which is what a home-rooted repo walk produces', () => {
    expect(projectLabel('/home/user', '/home/user')).toBe('home directory — not a project')
  })
})

describe('runningTime and idleTime', () => {
  const now = Date.parse('2026-08-07T03:00:00.000Z')
  const later = Date.parse('2026-08-08T03:00:00.000Z')

  it('ticks the running clock only while the session is running', () => {
    const live = runningTime(row('sess-tmux-live'), now)
    expect(live.kind).toBe('ticking')
    expect(live.kind === 'ticking' && live.startAtMs).toBe(Date.parse('2026-08-07T00:00:00.000Z'))
  })

  it('freezes the running clock of a finished session at its recorded end', () => {
    const finished = row('sess-cursor-finished')
    const first = runningTime(finished, now)
    const second = runningTime(finished, later)
    expect(first.kind).toBe('static')
    expect(second).toEqual(first)
    expect(first.kind === 'static' && first.ms).toBe(7_200_000)
  })

  it('measures an orphaned session to its last activity, since it has no end', () => {
    const orphan = runningTime(row('sess-orphaned-noresume'), now)
    expect(orphan).toEqual(runningTime(row('sess-orphaned-noresume'), later))
    expect(orphan.kind).toBe('static')
  })

  it('reports an idle clock only for a running session, and last-seen otherwise', () => {
    expect(idleTime(row('sess-codex-idle'), now).kind).toBe('ticking')
    expect(idleTime(row('sess-cursor-finished'), now).kind).toBe('lastSeen')
    expect(idleTime(row('sess-orphaned-noresume'), now).kind).toBe('lastSeen')
  })

  it('names the data bug rather than rendering a negative duration', () => {
    const bad = runningTime(row('sess-bad-clock'), now)
    expect(bad.kind).toBe('unknown')
    expect(bad.kind === 'unknown' && bad.problem).toContain('bad clock value')
  })

  it('says a finished session with no recorded end has an unknown run length', () => {
    const derived = runningTime(toAgentSessionRow(view({ state: 'FINISHED', finishedAt: null })), now)
    expect(derived.kind).toBe('unknown')
    expect(derived.kind === 'unknown' && derived.problem).toContain('no end time was recorded')
  })
})
