/**
 * src/http/projects.ts (v0.6 Project Manager — P2) — discover host projects.
 *
 * Modelled on history.ts: fully async, best-effort, never throws to the caller.
 * Steps (see docs/FEATURE_PROJECT_MANAGER.md §4.3):
 *   1. breadth-first scan cfg.projectRoots for git repos (depth-capped, skipping
 *      node_modules / dotdirs / symlinks — bounds the DoS/scan surface).
 *   2. per repo: branch from .git/HEAD (cheap, no spawn); optional dirty via
 *      `git status --porcelain` (execFile, no shell, timeout + concurrency cap).
 *   3. merge recently-used cwds from ~/.claude/projects (history.ts).
 *   4. merge live sessions by cwd → ProjectInfo.sessions[] (FRESH every call).
 *
 * Steps 1–3 (expensive disk work) are cached at module scope with a TTL; the
 * step-4 session merge runs fresh on every buildProjects call (sessions churn).
 */

import fs from 'node:fs/promises'
import path from 'node:path'
import { execFile } from 'node:child_process'
import { promisify } from 'node:util'
import type { Dirent } from 'node:fs'
import type {
  Config,
  LiveSessionInfo,
  ProjectInfo,
  ProjectSessionRef,
  ProjectDetail,
  SyncState,
  WorktreeState,
} from '../types.js'
import { listSessions } from './history.js'
import { listWorktrees } from './worktrees.js'

const execFileAsync = promisify(execFile)

// ── constants ─────────────────────────────────────────────────────────────────

const GIT_STATUS_TIMEOUT_MS = 2000 // hard kill on a slow `git status`
const GIT_CONCURRENCY = 8 // cap simultaneous git/fs work (fork-bomb guard)
const GIT_STATUS_MAX_BUFFER = 1024 * 1024 // truncate huge porcelain output
const MAX_PROJECTS = 200 // sane cap on the returned list
const SKIP_DIR_NAMES = new Set(['node_modules'])

// ── parse (pure, unit-tested) ───────────────────────────────────────────────────

/**
 * Parse a `.git/HEAD` file. `ref: refs/heads/<branch>` → '<branch>' (slashes
 * kept); a bare 40-hex sha (detached HEAD) or any junk → null.
 */
export function parseGitHead(headText: string): string | null {
  const match = /^ref:\s+refs\/heads\/(.+)$/.exec(headText.trim())
  if (match === null) return null
  const branch = match[1]?.trim()
  return branch !== undefined && branch !== '' ? branch : null
}

// ── small async utilities ───────────────────────────────────────────────────────

/** Run `fn` over `items` with at most `limit` in flight; preserves order. */
async function mapWithConcurrency<T, R>(
  items: readonly T[],
  limit: number,
  fn: (item: T) => Promise<R>,
): Promise<R[]> {
  const results: R[] = new Array<R>(items.length)
  let cursor = 0
  async function worker(): Promise<void> {
    for (;;) {
      const idx = cursor
      cursor += 1
      if (idx >= items.length) return
      const item = items[idx]
      if (item === undefined) continue
      results[idx] = await fn(item)
    }
  }
  const poolSize = Math.min(Math.max(limit, 1), items.length)
  await Promise.all(Array.from({ length: poolSize }, () => worker()))
  return results
}

/** Skip dotfiles/dotdirs (incl. .git) and known-heavy dirs when descending. */
function shouldSkipDir(name: string): boolean {
  return name.startsWith('.') || SKIP_DIR_NAMES.has(name)
}

// ── per-repo metadata (best-effort) ─────────────────────────────────────────────

/** Read the current branch from the repo's HEAD; undefined if unreadable or
 *  detached. Resolves `.git` first (w6/G1) so a linked worktree — where `.git` is
 *  a file, not a directory — reports its own branch instead of nothing. */
async function readBranch(repoPath: string): Promise<string | undefined> {
  const dirs = await resolveGitDirs(repoPath)
  if (dirs === null) return undefined
  try {
    const head = await fs.readFile(path.join(dirs.gitDir, 'HEAD'), 'utf8')
    return parseGitHead(head) ?? undefined
  } catch {
    return undefined
  }
}

/** `git status --porcelain` → dirty?; undefined on error/timeout/non-repo. */
async function readDirty(repoPath: string): Promise<boolean | undefined> {
  return (await readDirtyCount(repoPath)).dirty
}

/** w6/G1: one `git status --porcelain` → both the boolean and the line count, so
 *  the header can show `● 3` instead of a bare dot without a second spawn. Both
 *  fields degrade together (undefined on error/timeout/non-repo). */
async function readDirtyCount(
  repoPath: string,
): Promise<{ dirty?: boolean; dirtyCount?: number }> {
  try {
    const { stdout } = await execFileAsync('git', ['status', '--porcelain'], {
      cwd: repoPath,
      timeout: GIT_STATUS_TIMEOUT_MS,
      maxBuffer: GIT_STATUS_MAX_BUFFER,
    })
    const body = stdout.replace(/\n+$/, '')
    const count = body.length === 0 ? 0 : body.split('\n').length
    return { dirty: count > 0, dirtyCount: count }
  } catch {
    return {}
  }
}

/**
 * w6/G1: resolve a repo's git directories without spawning git.
 *
 *  - `gitDir` — where HEAD lives. For a linked worktree `<repo>/.git` is a *file*
 *    containing `gitdir: <path>`, not a directory (the pre-existing readBranch
 *    assumed a directory, so it silently failed inside every worktree).
 *  - `commonDir` — where FETCH_HEAD lives, shared by all worktrees of a repo. For a
 *    linked worktree that is `<common>/worktrees/<name>` → up two levels.
 *
 * Returns null when `<repo>/.git` is missing or unreadable.
 */
async function resolveGitDirs(
  repoPath: string,
): Promise<{ gitDir: string; commonDir: string } | null> {
  const dotGit = path.join(repoPath, '.git')
  let stat
  try {
    stat = await fs.stat(dotGit)
  } catch {
    return null
  }
  if (stat.isDirectory()) return { gitDir: dotGit, commonDir: dotGit }

  try {
    const text = await fs.readFile(dotGit, 'utf8')
    const match = /^gitdir:\s*(.+)$/m.exec(text)
    const raw = match?.[1]?.trim()
    if (raw === undefined || raw === '') return null
    const gitDir = path.isAbsolute(raw) ? raw : path.resolve(repoPath, raw)
    const parent = path.dirname(gitDir)
    const commonDir = path.basename(parent) === 'worktrees' ? path.dirname(parent) : gitDir
    return { gitDir, commonDir }
  } catch {
    return null
  }
}

/** w6/G1: `<commonDir>/FETCH_HEAD` mtime, or undefined if the repo was never
 *  fetched. A file stat, no spawn. Undefined must stay undefined — inventing a
 *  timestamp here would make a stale `behind` look freshly verified. */
async function readLastFetchMs(commonDir: string): Promise<number | undefined> {
  try {
    const stat = await fs.stat(path.join(commonDir, 'FETCH_HEAD'))
    return stat.mtimeMs
  } catch {
    return undefined
  }
}

/** w6/G1: the upstream's short name (`origin/develop`) for the current branch;
 *  undefined when the branch tracks nothing or HEAD is detached. */
async function readUpstream(repoPath: string): Promise<string | undefined> {
  try {
    const { stdout } = await execFileAsync(
      'git',
      ['rev-parse', '--abbrev-ref', '--symbolic-full-name', '@{u}'],
      { cwd: repoPath, timeout: GIT_STATUS_TIMEOUT_MS, maxBuffer: GIT_STATUS_MAX_BUFFER },
    )
    const name = stdout.trim()
    return name === '' ? undefined : name
  } catch {
    return undefined
  }
}

/** w6/G1: the full sync state for the detail header — upstream + ahead/behind +
 *  last fetch + detached. Reuses readSync for the counts (same two git calls the
 *  project list already makes) and adds two cheap reads on top. Never throws. */
async function readSyncState(repoPath: string): Promise<SyncState> {
  const dirs = await resolveGitDirs(repoPath)
  const [counts, upstream, lastFetchMs, headText] = await Promise.all([
    readSync(repoPath),
    readUpstream(repoPath),
    dirs === null ? Promise.resolve(undefined) : readLastFetchMs(dirs.commonDir),
    dirs === null
      ? Promise.resolve(null)
      : fs.readFile(path.join(dirs.gitDir, 'HEAD'), 'utf8').catch(() => null),
  ])

  const state: SyncState = {}
  if (upstream !== undefined) state.upstream = upstream
  if (counts.ahead !== undefined) state.ahead = counts.ahead
  if (counts.behind !== undefined) state.behind = counts.behind
  if (lastFetchMs !== undefined) state.lastFetchMs = lastFetchMs
  if (headText !== null && parseGitHead(headText) === null) {
    state.detached = true
    // A detached HEAD file holds the raw object id and nothing else. It is the
    // only identity the cell can show — there is no branch name — and it costs
    // no extra I/O, since the file is already read above for the detached test.
    const sha = headText.trim()
    if (/^[0-9a-f]{7,40}$/i.test(sha)) state.head = sha.slice(0, 7)
  }
  return state
}

/** W3 quick-wins (a): best-effort ahead/behind vs upstream + last-commit time.
 *  Two read-only git calls (no shell), each bounded by timeout + maxBuffer:
 *   1. `git rev-list --count --left-right @{u}...HEAD` → "<behind>\t<ahead>"
 *      (left = commits on @{u} not HEAD = behind; right = HEAD not @{u} = ahead).
 *      No upstream (`@{u}` fatal) / detached / empty repo → ahead/behind undefined.
 *   2. `git log -1 --format=%ct` → HEAD commit unix seconds → lastCommitMs (×1000).
 *  Every field degrades to undefined independently; never throws. */
async function readSync(repoPath: string): Promise<{
  ahead?: number
  behind?: number
  lastCommitMs?: number
}> {
  const out: { ahead?: number; behind?: number; lastCommitMs?: number } = {}

  try {
    const { stdout } = await execFileAsync(
      'git',
      ['rev-list', '--count', '--left-right', '@{u}...HEAD'],
      { cwd: repoPath, timeout: GIT_STATUS_TIMEOUT_MS, maxBuffer: GIT_STATUS_MAX_BUFFER },
    )
    const parts = stdout.trim().split(/\s+/)
    const behind = Number.parseInt(parts[0] ?? '', 10)
    const ahead = Number.parseInt(parts[1] ?? '', 10)
    if (Number.isFinite(behind) && behind >= 0) out.behind = behind
    if (Number.isFinite(ahead) && ahead >= 0) out.ahead = ahead
  } catch {
    // no upstream / detached / empty repo → leave ahead/behind undefined
  }

  try {
    const { stdout } = await execFileAsync('git', ['log', '-1', '--format=%ct'], {
      cwd: repoPath,
      timeout: GIT_STATUS_TIMEOUT_MS,
      maxBuffer: GIT_STATUS_MAX_BUFFER,
    })
    const secs = Number.parseInt(stdout.trim(), 10)
    if (Number.isFinite(secs) && secs >= 0) out.lastCommitMs = secs * 1000
  } catch {
    // empty repo (no commits) → leave lastCommitMs undefined
  }

  return out
}

/** True iff `<dir>/.git` exists (file or directory). */
async function hasGitEntry(dir: string): Promise<boolean> {
  try {
    await fs.stat(path.join(dir, '.git'))
    return true
  } catch {
    return false
  }
}

interface MakeProjectArgs {
  readonly path: string
  readonly isGit: boolean
  readonly branch?: string
  readonly dirty?: boolean
  readonly lastActiveMs?: number
  readonly ahead?: number
  readonly behind?: number
  readonly lastCommitMs?: number
}

function makeProject(args: MakeProjectArgs): ProjectInfo {
  return {
    name: path.basename(args.path),
    path: args.path,
    isGit: args.isGit,
    branch: args.branch,
    dirty: args.dirty,
    lastActiveMs: args.lastActiveMs,
    ahead: args.ahead,
    behind: args.behind,
    lastCommitMs: args.lastCommitMs,
    sessions: [],
  }
}

// ── step 1: breadth-first repo scan ─────────────────────────────────────────────

/**
 * BFS each root to `maxDepth`. A directory containing a `.git` entry is recorded
 * as a repo and NOT descended into. Unreadable dirs are skipped (best-effort);
 * node_modules / dotdirs / symlinks are never followed.
 */
async function scanRepos(roots: readonly string[], maxDepth: number): Promise<string[]> {
  const found: string[] = []
  const seen = new Set<string>()
  const queue: Array<{ dir: string; depth: number }> = roots.map((r) => ({
    dir: path.resolve(r),
    depth: 0,
  }))

  while (queue.length > 0) {
    const next = queue.shift()
    if (next === undefined) break
    const { dir, depth } = next
    if (seen.has(dir)) continue
    seen.add(dir)

    let entries: Dirent[]
    try {
      entries = await fs.readdir(dir, { withFileTypes: true })
    } catch {
      continue // unreadable dir → skip
    }

    if (entries.some((e) => e.name === '.git')) {
      found.push(dir)
      continue // don't descend into a repo
    }
    if (depth >= maxDepth) continue

    for (const entry of entries) {
      if (entry.isSymbolicLink()) continue // never follow symlinks
      if (!entry.isDirectory()) continue
      if (shouldSkipDir(entry.name)) continue
      queue.push({ dir: path.join(dir, entry.name), depth: depth + 1 })
    }
  }

  return found
}

// ── step 3: merge recently-used cwds from history ───────────────────────────────

/** Newest mtime per distinct (non-empty) cwd seen in ~/.claude/projects. */
async function lastActiveByCwd(): Promise<Map<string, number>> {
  let sessions
  try {
    sessions = await listSessions()
  } catch {
    return new Map()
  }
  const byCwd = new Map<string, number>()
  for (const s of sessions) {
    if (s.cwd === '') continue
    const prev = byCwd.get(s.cwd)
    if (prev === undefined || s.mtimeMs > prev) byCwd.set(s.cwd, s.mtimeMs)
  }
  return byCwd
}

async function mergeHistory(repos: ProjectInfo[]): Promise<ProjectInfo[]> {
  const byCwd = await lastActiveByCwd()
  const knownPaths = new Set(repos.map((p) => p.path))

  // Stamp discovered repos that also appear in history.
  const stamped = repos.map((p) => {
    const ms = byCwd.get(p.path)
    return ms === undefined ? p : { ...p, lastActiveMs: ms }
  })

  // Add history cwds not already in discovered repos — processed concurrently.
  const historyEntries = [...byCwd.entries()].filter(([cwd]) => !knownPaths.has(cwd))
  const extras = await mapWithConcurrency(
    historyEntries,
    GIT_CONCURRENCY,
    async ([cwd, ms]) => {
      const isGit = await hasGitEntry(cwd)
      const branch = isGit ? await readBranch(cwd) : undefined
      return makeProject({ path: cwd, isGit, branch, lastActiveMs: ms })
    },
  )

  return [...stamped, ...extras]
}

// ── steps 1–3: cached disk discovery ─────────────────────────────────────────────

interface DiscoverCacheEntry {
  readonly key: string
  readonly expiresAt: number
  readonly projects: ProjectInfo[]
}

let discoverCache: DiscoverCacheEntry | null = null
/** Shared in-flight promise so concurrent cache-miss callers join one discovery run. */
let inflightDiscovery: { key: string; promise: Promise<ProjectInfo[]> } | null = null

function cacheKey(cfg: Config): string {
  return JSON.stringify({
    roots: cfg.projectRoots,
    depth: cfg.projectScanDepth,
    dirty: cfg.projectDirtyCheck,
  })
}

function dedupByPath(projects: readonly ProjectInfo[]): ProjectInfo[] {
  const seen = new Set<string>()
  const out: ProjectInfo[] = []
  for (const p of projects) {
    if (seen.has(p.path)) continue
    seen.add(p.path)
    out.push(p)
  }
  return out
}

/**
 * Drop bare parent folders: a non-git entry (from session history — e.g. the
 * home dir or ~/Documents, where a session once ran) that merely contains
 * other discovered projects is not a project itself; listing it would also
 * double-claim its children's sessions in the UI.
 */
function dropParentFolders(projects: readonly ProjectInfo[]): ProjectInfo[] {
  return projects.filter(
    (p) => p.isGit || !projects.some((q) => q.path !== p.path && belongsTo(q.path, p.path)),
  )
}

async function runDiscovery(cfg: Config): Promise<ProjectInfo[]> {
  const repoPaths = await scanRepos(cfg.projectRoots, cfg.projectScanDepth)
  const repos = await mapWithConcurrency(repoPaths, GIT_CONCURRENCY, async (repoPath) => {
    const branch = await readBranch(repoPath)
    // W3(a): the sync chip (ahead/behind + last-commit) rides the same per-repo
    // git budget as the dirty check — gated by projectDirtyCheck, best-effort.
    const dirty = cfg.projectDirtyCheck ? await readDirty(repoPath) : undefined
    const sync = cfg.projectDirtyCheck ? await readSync(repoPath) : {}
    return makeProject({ path: repoPath, isGit: true, branch, dirty, ...sync })
  })
  const merged = await mergeHistory(repos)
  return dropParentFolders(dedupByPath(merged))
}

/**
 * Cached steps 1–3, keyed by (roots+depth+dirtyCheck) with cfg.projectScanTtlMs.
 * Concurrent cache-miss callers share a single in-flight promise (cache-stampede guard).
 */
async function discoverProjects(cfg: Config): Promise<ProjectInfo[]> {
  const now = Date.now()
  const key = cacheKey(cfg)
  if (discoverCache !== null && discoverCache.key === key && discoverCache.expiresAt > now) {
    return discoverCache.projects
  }
  // Deduplicate concurrent cache-miss calls onto one discovery run.
  if (inflightDiscovery !== null && inflightDiscovery.key === key) {
    return inflightDiscovery.promise
  }
  const promise = runDiscovery(cfg)
    .then((projects) => {
      discoverCache = { key, expiresAt: Date.now() + cfg.projectScanTtlMs, projects }
      inflightDiscovery = null
      return projects
    })
    .catch((e: unknown) => {
      inflightDiscovery = null
      throw e
    })
  inflightDiscovery = { key, promise }
  return promise
}

/** Test-only: drop the discovery cache and any in-flight run so each test sees a fresh scan. */
export function _clearProjectCache(): void {
  discoverCache = null
  inflightDiscovery = null
}

// ── step 4: fresh live-session merge + sort ──────────────────────────────────────

function lastSegment(cwd: string | null): string | undefined {
  if (cwd === null || cwd === '') return undefined
  return cwd.split(path.sep).filter(Boolean).pop()
}

/** A session belongs to a project when its spawn cwd is the path or under it. */
function belongsTo(cwd: string | null, projectPath: string): boolean {
  if (cwd === null) return false
  if (cwd === projectPath) return true
  return cwd.startsWith(projectPath + path.sep)
}

function toSessionRef(s: LiveSessionInfo): ProjectSessionRef {
  return {
    id: s.id,
    title: lastSegment(s.cwd),
    status: s.status,
    clientCount: s.clientCount,
    createdAt: s.createdAt,
    exited: s.exited,
    // w6/G7: without cwd the UI cannot say WHICH worktree a session runs in —
    // and with one worktree per session that is the question being asked.
    ...(s.cwd !== null ? { cwd: s.cwd } : {}),
  }
}

function matchSessions(projectPath: string, live: readonly LiveSessionInfo[]): ProjectSessionRef[] {
  return live.filter((s) => belongsTo(s.cwd, projectPath)).map(toSessionRef)
}

/**
 * Attribute each live session to the DEEPEST project containing its cwd.
 * Plain prefix matching would also light up every ancestor project (e.g. a
 * nested repo's session showing as active on the containing repo too).
 */
function assignSessions(
  projects: readonly ProjectInfo[],
  live: readonly LiveSessionInfo[],
): Map<string, ProjectSessionRef[]> {
  const byPath = new Map<string, ProjectSessionRef[]>()
  for (const s of live) {
    let deepest: string | undefined
    for (const p of projects) {
      if (!belongsTo(s.cwd, p.path)) continue
      if (deepest === undefined || p.path.length > deepest.length) deepest = p.path
    }
    if (deepest === undefined) continue
    const refs = byPath.get(deepest) ?? []
    byPath.set(deepest, [...refs, toSessionRef(s)])
  }
  return byPath
}

function sortProjects(projects: readonly ProjectInfo[]): ProjectInfo[] {
  return [...projects].sort((a, b) => {
    if (a.lastActiveMs !== b.lastActiveMs) {
      if (a.lastActiveMs === undefined) return 1 // undefined sorts last
      if (b.lastActiveMs === undefined) return -1
      return b.lastActiveMs - a.lastActiveMs // most-recent first
    }
    return a.name.localeCompare(b.name)
  })
}

/**
 * Discover the host's projects and attach the currently-running sessions.
 * `liveSessions` is INJECTED (e.g. `manager.list()`) so this stays pure-testable.
 * Disk discovery is cached; the session merge always runs fresh.
 */
export async function buildProjects(
  cfg: Config,
  liveSessions: readonly LiveSessionInfo[],
): Promise<ProjectInfo[]> {
  const base = await discoverProjects(cfg)
  const sessionsByPath = assignSessions(base, liveSessions)
  const withSessions = base.map((p) => ({
    ...p,
    sessions: sessionsByPath.get(p.path) ?? [],
  }))
  return sortProjects(withSessions).slice(0, MAX_PROJECTS)
}

const CLAUDE_MD_MAX_BYTES = 64 * 1024 // cap CLAUDE.md content sent for display

/** Read <repo>/CLAUDE.md (truncated); undefined when absent/unreadable. */
async function readClaudeMd(projectPath: string): Promise<string | undefined> {
  try {
    const text = await fs.readFile(path.join(projectPath, 'CLAUDE.md'), 'utf8')
    return text.length > CLAUDE_MD_MAX_BYTES
      ? text.slice(0, CLAUDE_MD_MAX_BYTES) + '\n\n…(truncated)'
      : text
  } catch {
    return undefined
  }
}

/**
 * Detail for one project: branch + dirty + worktrees + its running sessions.
 * `projectPath` must be an absolute, existing directory (else null — 404). Not
 * cached: the detail view is opened on demand and wants fresh worktree/session
 * state. Reads only (git branch/status/worktree-list); never throws.
 */
/**
 * w6/G7 — the git state of ONE worktree, probed on demand.
 *
 * A linked worktree is just a directory with a `.git` FILE, so the same readers
 * the project detail uses work verbatim once resolveGitDirs handles that shape.
 * Kept as its own small call (rather than reusing buildProjectDetail) because a
 * row only needs branch/sync/dirty — listing worktrees and reading CLAUDE.md for
 * every row would spend spawns on data the row never shows.
 *
 * Returns null for a missing path or a non-git directory. Never throws.
 */
export async function buildWorktreeState(
  cfg: Config,
  worktreePath: string,
): Promise<WorktreeState | null> {
  if (typeof worktreePath !== 'string' || !path.isAbsolute(worktreePath)) return null
  try {
    const stat = await fs.stat(worktreePath)
    if (!stat.isDirectory()) return null
  } catch {
    return null
  }
  if (!(await hasGitEntry(worktreePath))) return null

  const [branch, dirtyState, sync] = await Promise.all([
    readBranch(worktreePath),
    cfg.projectDirtyCheck
      ? readDirtyCount(worktreePath)
      : Promise.resolve<{ dirty?: boolean; dirtyCount?: number }>({}),
    readSyncState(worktreePath),
  ])

  return {
    path: worktreePath,
    ...(branch !== undefined ? { branch } : {}),
    ...(dirtyState.dirtyCount !== undefined ? { dirtyCount: dirtyState.dirtyCount } : {}),
    sync,
  }
}

/**
 * w6/G6 — coalesce concurrent probes of the SAME repo into one.
 *
 * A detail view refreshes every 5 s and spawns several `git` processes per pass,
 * on the machine that is also running Claude Code and builds. With the app open
 * on a laptop, a phone and a tablet, that cost multiplied by the number of
 * devices for no added information.
 *
 * This deliberately caches NOTHING across time: the entry is dropped the moment
 * it settles, so a later call always re-probes. A time-based cache would be the
 * cheaper fix and the wrong one — this panel's entire value is that its numbers
 * are true right now, and a stale `ahead` after a push is exactly the kind of
 * confident lie the design rules out.
 */
const inFlightDetails = new Map<string, Promise<ProjectDetail | null>>()

export function buildProjectDetail(
  cfg: Config,
  projectPath: string,
  liveSessions: readonly LiveSessionInfo[],
): Promise<ProjectDetail | null> {
  const existing = inFlightDetails.get(projectPath)
  if (existing !== undefined) return existing

  const pending = buildProjectDetailUncoalesced(cfg, projectPath, liveSessions).finally(() => {
    inFlightDetails.delete(projectPath)
  })
  inFlightDetails.set(projectPath, pending)
  return pending
}

async function buildProjectDetailUncoalesced(
  cfg: Config,
  projectPath: string,
  liveSessions: readonly LiveSessionInfo[],
): Promise<ProjectDetail | null> {
  if (typeof projectPath !== 'string' || !path.isAbsolute(projectPath)) return null

  let stat
  try {
    stat = await fs.stat(projectPath)
  } catch {
    return null
  }
  if (!stat.isDirectory()) return null

  const isGit = await hasGitEntry(projectPath)
  const [branch, dirtyState, sync, worktrees, claudeMd] = await Promise.all([
    isGit ? readBranch(projectPath) : Promise.resolve(undefined),
    isGit && cfg.projectDirtyCheck
      ? readDirtyCount(projectPath)
      : Promise.resolve<{ dirty?: boolean; dirtyCount?: number }>({}),
    isGit ? readSyncState(projectPath) : Promise.resolve(undefined),
    isGit ? listWorktrees(projectPath) : Promise.resolve([]),
    readClaudeMd(projectPath),
  ])

  return {
    name: path.basename(projectPath),
    path: projectPath,
    isGit,
    branch,
    dirty: dirtyState.dirty,
    dirtyCount: dirtyState.dirtyCount,
    sync,
    worktrees,
    sessions: matchSessions(projectPath, liveSessions),
    hasClaudeMd: claudeMd !== undefined,
    claudeMd,
  }
}
