/**
 * The typed boundary between the collector's sessions panel and the observability UI.
 *
 * The collector speaks the ledger's vocabulary (5 liveness states, recorder field names);
 * the panel renders the pinned agent-session contract (4 states, running/idle derived).
 * Every difference is absorbed HERE — no component reads a raw panel field, and no absent
 * field is ever back-filled from an unrelated one.
 *
 * Structural mirror of collector/src/adapters/sessions.ts. Duplicated, not imported —
 * `collector` sits outside the pnpm workspace glob.
 */

export type LedgerState =
  | 'ALIVE-WORKING'
  | 'ALIVE-IDLE'
  | 'DETACHED-ALIVE'
  | 'ORPHANED'
  | 'FINISHED'

export type AttachKind = 'tmux' | 'resume' | 'ssh' | 'none'

export interface AttachDescriptor {
  kind: AttachKind
  target?: string
  command?: string
  reason?: string
}

export interface HostFacts {
  name: string
  reachability: 'declared-enabled' | 'declared-disabled' | 'unknown'
  /** The registry's own word for the host's condition; null when it lists no host. */
  state: 'reachable' | 'unreachable' | 'bricked' | null
  notes: string | null
}

/** One row exactly as the collector serves it. Read only by the adapter below. */
export interface SessionView {
  id: string
  runtime: string
  pid: number | null
  cwd: string
  repo: string | null
  repoRoot: string
  branch: string | null
  worktree: string | null
  host: string | null
  startedAt: string | null
  lastHeartbeatAt: string | null
  lastProgressAt: string | null
  lastActiveAt: string | null
  finishedAt: string | null
  finishReason: string | null
  transcriptPath: string | null
  tmuxSession: string | null
  tmuxSocket: string | null
  mux: { kind: string | null; socket: string | null; target: string | null } | null
  attached: boolean | null
  evidence: string | null
  parent: string | null
  parentLedgerId: string | null
  state: LedgerState
  resumeId?: string | null
  launchedBy: string | null
  title: string | null
  activity: string | null
  account: string | null
  project: string
  hostFacts: HostFacts | null
  attach: AttachDescriptor
  attachable: boolean
  attachBlockedReason?: string
}

export interface SessionsPanelData {
  ledgerDir: string
  ledgerMissing: boolean
  unreadableFiles: string[]
  tmuxAvailable: boolean
  attachEnabled: boolean
  attachBlockedReason?: string
  localHost: string
  homeDir: string | null
  hosts: HostFacts[]
  hostsRegistryPath: string
  hostsRegistryMissing: boolean
  hostProbes: Array<{
    host: string
    status: 'pending' | 'ok' | 'failed' | 'not-reachable'
    error: string | null
    at: string
    sessionCount: number
  }>
  sessions: SessionView[]
}

export interface SessionScreenResponse {
  ok: boolean
  screen: string
  capturedAt: string
  sessionState?: string
  error?: string
}

export type PinnedState = 'live' | 'idle' | 'finished' | 'unknown'
export type LaunchedBy = 'user' | 'factory' | 'agent'

/**
 * A duration the UI may show. `ms` is present only when the arithmetic produced a sane,
 * non-negative value; a negative or unparseable one is a DATA BUG (a prior recorder wrote
 * local time labelled Z) and is surfaced as unknown with the offending value named, never
 * rendered as a duration.
 */
export type Duration =
  | { known: true; ms: number; sinceIso: string }
  | { known: false; problem: string | null }

const UNKNOWN_DURATION: Duration = { known: false, problem: null }

export function durationSince(iso: string | null, nowMs: number): Duration {
  if (!iso) return UNKNOWN_DURATION
  const started = Date.parse(iso)
  if (Number.isNaN(started)) return { known: false, problem: `unparseable timestamp "${iso}"` }
  const ms = nowMs - started
  if (ms < 0) return { known: false, problem: `timestamp "${iso}" is in the future — the recorder wrote a bad clock value` }
  return { known: true, ms, sinceIso: iso }
}

/**
 * How a duration column must render. A running session's clock ticks; a finished or
 * orphaned one's is FROZEN at the last moment we have evidence for. A dead session whose
 * running time keeps climbing is exactly the invented liveness this panel exists to remove.
 */
export type Elapsed =
  | { kind: 'ticking'; startAtMs: number }
  | { kind: 'static'; ms: number }
  | { kind: 'lastSeen'; atMs: number }
  | { kind: 'unknown'; problem: string | null }

function epochMs(iso: string | null): number | null {
  if (!iso) return null
  const parsed = Date.parse(iso)
  return Number.isNaN(parsed) ? null : parsed
}

export function isRunning(state: PinnedState): boolean {
  return state === 'live' || state === 'idle'
}

/** Wall-clock length of the run: ticking while it runs, frozen once it does not. */
export function runningTime(row: AgentSessionRow, nowMs: number): Elapsed {
  const started = durationSince(row.startedAt, nowMs)
  if (!started.known) return { kind: 'unknown', problem: started.problem }
  const startAtMs = epochMs(row.startedAt)!

  if (isRunning(row.state)) return { kind: 'ticking', startAtMs }

  // Finished sessions are measured to their recorded end; orphaned ones have no end, so
  // the last activity is the furthest we can honestly claim the session ran.
  const endIso = row.state === 'finished' ? row.finishedAt : row.lastActiveAt
  const endAtMs = epochMs(endIso)
  if (endAtMs === null) {
    return {
      kind: 'unknown',
      problem: row.state === 'finished'
        ? 'no end time was recorded for this finished session, so its run length is unknown'
        : 'no activity was recorded for this session, so how long it ran is unknown',
    }
  }
  if (endAtMs < startAtMs) {
    return { kind: 'unknown', problem: `end time "${endIso}" precedes start time "${row.startedAt}" — the recorder wrote a bad clock value` }
  }
  return { kind: 'static', ms: endAtMs - startAtMs }
}

/** Time since the last observed activity — an idle clock only while the session runs. */
export function idleTime(row: AgentSessionRow, nowMs: number): Elapsed {
  if (isRunning(row.state)) {
    const since = durationSince(row.lastActiveAt, nowMs)
    if (!since.known) return { kind: 'unknown', problem: since.problem }
    return { kind: 'ticking', startAtMs: epochMs(row.lastActiveAt)! }
  }
  const seenIso = row.lastActiveAt ?? row.finishedAt
  const since = durationSince(seenIso, nowMs)
  if (!since.known) return { kind: 'unknown', problem: since.problem }
  return { kind: 'lastSeen', atMs: epochMs(seenIso)! }
}

const PINNED_STATE: Record<LedgerState, PinnedState> = {
  'ALIVE-WORKING': 'live',
  'ALIVE-IDLE': 'idle',
  'DETACHED-ALIVE': 'live',
  ORPHANED: 'unknown',
  FINISHED: 'finished',
}

/** Chip vocabulary keeps the LEDGER state, not the pinned one: "detached — reattachable"
 * and "orphaned" are different actions for the operator, and flattening them to `live`
 * / `unknown` would hide the two rows that need a human. */
const STATE_CHIP: Record<LedgerState, { status: string; label: string }> = {
  'ALIVE-WORKING': { status: 'running', label: 'working' },
  'ALIVE-IDLE': { status: 'queued', label: 'idle' },
  'DETACHED-ALIVE': { status: 'attention', label: 'detached — reattachable' },
  ORPHANED: { status: 'failed', label: 'orphaned' },
  FINISHED: { status: 'completed', label: 'finished' },
}

export function sessionStateChip(state: LedgerState): { status: string; label: string } {
  return STATE_CHIP[state]
}

const LAUNCHED_BY: readonly string[] = ['user', 'factory', 'agent']

/** The row the panel renders — the pinned contract, every optional field honestly null. */
export interface AgentSessionRow {
  id: string
  cli: string
  host: string | null
  hostFacts: HostFacts | null
  /** Null when the recorder stamped nothing AND nothing launched this session: unknown. */
  launchedBy: LaunchedBy | null
  parent: string | null
  project: string
  branch: string | null
  title: string | null
  activity: string | null
  startedAt: string | null
  lastActiveAt: string | null
  finishedAt: string | null
  /** Whoever closed the row said this, verbatim; null when the row is open or said nothing. */
  finishReason: string | null
  state: PinnedState
  ledgerState: LedgerState
  /** Classifier verdict that a terminal client is on the session; null when unknown. */
  attached: boolean | null
  evidence: string | null
  attach: AttachDescriptor
  /** Browser screen rendering, narrower than `attach`. */
  screenAttachable: boolean
  screenBlockedReason: string | null
  /** Whether an account was recorded for the resume command; false ⇒ named as a gap. */
  accountKnown: boolean
  source: SessionView
}

/**
 * `launchedBy` is only ever reported, never guessed — except for the one case the ledger
 * proves: an entry whose `parentLedgerId` is set was started by another session.
 */
function launchedBy(session: SessionView): LaunchedBy | null {
  if (session.launchedBy && LAUNCHED_BY.includes(session.launchedBy)) {
    return session.launchedBy as LaunchedBy
  }
  return session.parentLedgerId ? 'agent' : null
}

export function toAgentSessionRow(session: SessionView): AgentSessionRow {
  return {
    id: session.id,
    cli: session.runtime,
    host: session.host,
    hostFacts: session.hostFacts,
    launchedBy: launchedBy(session),
    parent: session.parent ?? session.parentLedgerId,
    project: session.project,
    branch: session.branch,
    title: session.title,
    activity: session.activity,
    startedAt: session.startedAt,
    // NEVER falls back to startedAt: a start time rendered as activity is what made the
    // old panel claim a wedged session was working.
    lastActiveAt: session.lastActiveAt ?? session.lastProgressAt ?? session.lastHeartbeatAt,
    finishedAt: session.finishedAt,
    finishReason: session.finishReason,
    state: PINNED_STATE[session.state],
    ledgerState: session.state,
    attached: session.attached,
    evidence: session.evidence,
    attach: session.attach,
    screenAttachable: session.attachable,
    screenBlockedReason: session.attachBlockedReason ?? null,
    accountKnown: session.account !== null,
    source: session,
  }
}

export interface SessionProjectGroup {
  project: string
  rows: AgentSessionRow[]
  liveCount: number
  /** Newest known activity in the group; null when no row has a usable timestamp. */
  latestActivityMs: number | null
}

function activityMs(row: AgentSessionRow): number | null {
  const raw = row.lastActiveAt ?? row.startedAt
  if (!raw) return null
  const parsed = Date.parse(raw)
  return Number.isNaN(parsed) ? null : parsed
}

/**
 * Groups every project the ledger knows, live rows first — both across groups and within
 * one. Sorting uses `lastActiveAt ?? startedAt` because a row must still be orderable when
 * it has no activity signal; DISPLAY never makes that substitution.
 */
export function groupSessionsByProject(rows: AgentSessionRow[]): SessionProjectGroup[] {
  const groups = new Map<string, AgentSessionRow[]>()
  for (const row of rows) {
    const bucket = groups.get(row.project)
    if (bucket) bucket.push(row)
    else groups.set(row.project, [row])
  }

  const isLive = (row: AgentSessionRow): boolean => row.state === 'live' || row.state === 'idle'

  return [...groups.entries()]
    .map(([project, entries]) => {
      const sorted = [...entries].sort((left, right) => {
        if (isLive(left) !== isLive(right)) return isLive(left) ? -1 : 1
        const leftMs = activityMs(left)
        const rightMs = activityMs(right)
        if (leftMs === null) return rightMs === null ? 0 : 1
        if (rightMs === null) return -1
        return rightMs - leftMs
      })
      return {
        project,
        rows: sorted,
        liveCount: sorted.filter(isLive).length,
        latestActivityMs: sorted.reduce<number | null>((best, row) => {
          const ms = activityMs(row)
          if (ms === null) return best
          return best === null || ms > best ? ms : best
        }, null),
      }
    })
    .sort((left, right) => {
      if ((left.liveCount > 0) !== (right.liveCount > 0)) return left.liveCount > 0 ? -1 : 1
      if (left.latestActivityMs === null) return right.latestActivityMs === null ? 0 : 1
      if (right.latestActivityMs === null) return -1
      return right.latestActivityMs - left.latestActivityMs
    })
}

/**
 * Short, stable label for a project path; the full path is printed under it. The parent
 * directory is kept because a main checkout and a build clone of the same repo
 * (`~/Projects/overdeck` and `~/builds/overdeck`) are different working trees and must
 * never collapse into one section — the table's accessible name is derived from this.
 */
export function projectLabel(project: string, homeDir: string | null = null): string {
  const relative = homeDir && project.startsWith(`${homeDir}/`)
    ? project.slice(homeDir.length + 1)
    : project
  if (homeDir && project === homeDir) return 'home directory — not a project'
  const parts = relative.split('/').filter(Boolean)
  if (parts.length === 0) return project
  const worktreeAt = parts.indexOf('.worktrees')
  if (worktreeAt > 0 && parts.length > worktreeAt + 1) {
    return `${parts[worktreeAt - 1]} · ${parts[worktreeAt + 1]}`
  }
  const name = parts[parts.length - 1]!
  const parent = parts.length > 1 ? parts[parts.length - 2]! : null
  return parent ? `${parent}/${name}` : name
}
