import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
import fs from 'node:fs/promises'
import os from 'node:os'
import path from 'node:path'
import { execFile } from 'node:child_process'
import { promisify } from 'node:util'
import type { Config, LiveSessionInfo } from '../src/types.js'
import type { HistorySession } from '../src/http/history.js'

// ── mock history.listSessions so the real ~/.claude/projects never pollutes ──────
// (vitest hoists vi.mock; the factory may only reference vars prefixed "mock".)
const mockListSessions = vi.fn(async (): Promise<HistorySession[]> => [])
vi.mock('../src/http/history.js', () => ({
  listSessions: (...a: unknown[]) => mockListSessions(...a),
}))

const {
  parseGitHead,
  buildProjects,
  buildProjectDetail,
  buildWorktreeState,
  _clearProjectCache,
} = await import('../src/http/projects.js')

const execFileP = promisify(execFile)

// ── helpers ─────────────────────────────────────────────────────────────────────

function makeCfg(overrides: Partial<Config>): Config {
  return {
    port: 3000,
    bindHost: '0.0.0.0',
    shellPath: '/bin/zsh',
    homeDir: '/home/tester',
    idleTtlMs: 1000,
    scrollbackBytes: 1024,
    maxPayloadBytes: 1024,
    wsPath: '/term',
    maxSessions: 50,
    maxMsgsPerSec: 2000,
    permTimeoutMs: 1000,
    reapIntervalMs: 1000,
    previewBytes: 1024,
    useTmux: false,
    allowedOrigins: [],
    projectRoots: [],
    projectScanDepth: 4,
    projectScanTtlMs: 0, // default OFF so each test re-scans deterministically
    projectDirtyCheck: false,
    ...overrides,
  }
}

function makeLive(over: { id: string; cwd: string | null } & Partial<LiveSessionInfo>): LiveSessionInfo {
  return {
    id: over.id,
    createdAt: over.createdAt ?? 1000,
    clientCount: over.clientCount ?? 1,
    status: over.status ?? 'unknown',
    exited: over.exited ?? false,
    cwd: over.cwd,
    cols: over.cols ?? 80,
    rows: over.rows ?? 24,
  }
}

async function makeRepo(dir: string, branch: string): Promise<void> {
  await fs.mkdir(path.join(dir, '.git'), { recursive: true })
  await fs.writeFile(path.join(dir, '.git', 'HEAD'), `ref: refs/heads/${branch}\n`)
}

async function gitAvailable(): Promise<boolean> {
  try {
    await execFileP('git', ['--version'])
    return true
  } catch {
    return false
  }
}

let tmp: string

beforeEach(async () => {
  _clearProjectCache()
  mockListSessions.mockReset()
  mockListSessions.mockResolvedValue([])
  tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'projtest-'))
})

afterEach(async () => {
  _clearProjectCache()
  await fs.rm(tmp, { recursive: true, force: true })
})

// ── parseGitHead (pure) ──────────────────────────────────────────────────────────

describe('parseGitHead', { timeout: 30_000 }, () => {
  it('extracts a simple branch name', () => {
    expect(parseGitHead('ref: refs/heads/main\n')).toBe('main')
  })

  it('extracts a branch name with slashes', () => {
    expect(parseGitHead('ref: refs/heads/feature/x\n')).toBe('feature/x')
  })

  it('returns null for a detached HEAD (bare 40-hex sha)', () => {
    expect(parseGitHead('a'.repeat(40) + '\n')).toBeNull()
  })

  it('returns null for empty / junk / non-heads refs', () => {
    expect(parseGitHead('')).toBeNull()
    expect(parseGitHead('garbage\n')).toBeNull()
    expect(parseGitHead('ref: refs/tags/v1\n')).toBeNull()
  })
})

// ── buildProjects: discovery (real temp dir) ─────────────────────────────────────

describe('buildProjects — discovery', { timeout: 30_000 }, () => {
  it('finds git repos with correct names/branches, skips node_modules, respects depth', async () => {
    await makeRepo(path.join(tmp, 'repoA'), 'main') // depth 1
    await makeRepo(path.join(tmp, 'sub', 'repoB'), 'dev') // depth 2
    await makeRepo(path.join(tmp, 'sub', 'deep', 'repoC'), 'x') // depth 3 -> excluded
    await makeRepo(path.join(tmp, 'node_modules', 'pkg'), 'y') // node_modules -> skipped

    const cfg = makeCfg({ projectRoots: [tmp], projectScanDepth: 2, projectDirtyCheck: false })
    const out = await buildProjects(cfg, [])
    const byName = new Map(out.map((p) => [p.name, p]))

    expect([...byName.keys()].sort()).toEqual(['repoA', 'repoB'])
    expect(byName.get('repoA')).toMatchObject({
      isGit: true,
      branch: 'main',
      path: path.join(tmp, 'repoA'),
      sessions: [],
    })
    expect(byName.get('repoB')!.branch).toBe('dev')
    expect(byName.has('repoC')).toBe(false) // too deep
    expect(byName.has('pkg')).toBe(false) // node_modules
  })

  it('records a root that is itself a git repo and does not descend into it', async () => {
    await makeRepo(tmp, 'main')
    await makeRepo(path.join(tmp, 'nested'), 'dev') // inside a repo -> not descended

    const cfg = makeCfg({ projectRoots: [tmp], projectScanDepth: 4, projectDirtyCheck: false })
    const out = await buildProjects(cfg, [])

    expect(out.map((p) => p.path)).toEqual([tmp])
    expect(out[0]!.branch).toBe('main')
  })

  it('leaves branch undefined when .git/HEAD is missing/unreadable', async () => {
    await fs.mkdir(path.join(tmp, 'bare', '.git'), { recursive: true }) // .git dir, no HEAD
    const cfg = makeCfg({ projectRoots: [tmp], projectScanDepth: 2, projectDirtyCheck: false })
    const out = await buildProjects(cfg, [])
    const bare = out.find((p) => p.name === 'bare')!
    expect(bare.isGit).toBe(true)
    expect(bare.branch).toBeUndefined()
  })

  it('does not follow symlinks', async () => {
    const other = await fs.mkdtemp(path.join(os.tmpdir(), 'projtest-link-'))
    try {
      await makeRepo(path.join(other, 'repoS'), 'main')
      try {
        await fs.symlink(other, path.join(tmp, 'linked'), 'dir')
      } catch {
        return // symlinks not permitted in this environment
      }
      const cfg = makeCfg({ projectRoots: [tmp], projectScanDepth: 4, projectDirtyCheck: false })
      const out = await buildProjects(cfg, [])
      expect(out.some((p) => p.name === 'repoS')).toBe(false)
    } finally {
      await fs.rm(other, { recursive: true, force: true })
    }
  })

  it('returns [] for an unreadable / missing root (never throws)', async () => {
    const cfg = makeCfg({
      projectRoots: [path.join(tmp, 'does-not-exist')],
      projectScanDepth: 2,
      projectDirtyCheck: false,
    })
    await expect(buildProjects(cfg, [])).resolves.toEqual([])
  })
})

// ── buildProjects: live-session merge ────────────────────────────────────────────

describe('buildProjects — session merge', { timeout: 30_000 }, () => {
  it('merges sessions into the owning project by cwd === path or under path', async () => {
    await makeRepo(path.join(tmp, 'repoA'), 'main')
    const repoPath = path.join(tmp, 'repoA')
    const cfg = makeCfg({ projectRoots: [tmp], projectScanDepth: 2, projectDirtyCheck: false })

    const live: LiveSessionInfo[] = [
      makeLive({ id: 's-exact', cwd: repoPath, status: 'working', clientCount: 2, createdAt: 111 }),
      makeLive({ id: 's-under', cwd: path.join(repoPath, 'src', 'http'), status: 'idle' }),
      makeLive({ id: 's-other', cwd: path.join(tmp, 'elsewhere') }),
      makeLive({ id: 's-null', cwd: null }),
    ]

    const out = await buildProjects(cfg, live)
    const repoA = out.find((p) => p.name === 'repoA')!

    expect(repoA.sessions.map((s) => s.id).sort()).toEqual(['s-exact', 's-under'])
    expect(repoA.sessions.find((s) => s.id === 's-exact')).toEqual({
      id: 's-exact',
      title: 'repoA',
      status: 'working',
      clientCount: 2,
      createdAt: 111,
      exited: false,
      cwd: repoPath, // w6/G7 — needed to attribute a session to its worktree
    })
    expect(repoA.sessions.find((s) => s.id === 's-under')!.title).toBe('http')
  })

  it('attributes a session only to the DEEPEST project containing its cwd', async () => {
    // repoA is discovered by the scan; the nested repoA/sub repo enters via
    // history (scan does not descend into repos). A session inside sub must
    // NOT also light up the ancestor repoA.
    const repoPath = path.join(tmp, 'repoA')
    const subPath = path.join(repoPath, 'sub')
    await makeRepo(repoPath, 'main')
    await makeRepo(subPath, 'dev')
    mockListSessions.mockResolvedValue([
      { id: 'h1', cwd: subPath, project: 'sub', mtimeMs: 1000, preview: '' },
    ])
    const cfg = makeCfg({ projectRoots: [tmp], projectScanDepth: 2, projectDirtyCheck: false })

    const live = [makeLive({ id: 's-deep', cwd: path.join(subPath, 'src') })]
    const out = await buildProjects(cfg, live)

    const repoA = out.find((p) => p.path === repoPath)!
    const sub = out.find((p) => p.path === subPath)!
    expect(sub.sessions.map((s) => s.id)).toEqual(['s-deep'])
    expect(repoA.sessions).toEqual([])
  })

  it('re-merges live sessions FRESH on every call (discovery is cached)', async () => {
    await makeRepo(path.join(tmp, 'repoA'), 'main')
    const repoPath = path.join(tmp, 'repoA')
    const cfg = makeCfg({
      projectRoots: [tmp],
      projectScanDepth: 2,
      projectDirtyCheck: false,
      projectScanTtlMs: 60_000, // keep discovery cached across calls
    })

    const first = await buildProjects(cfg, [])
    expect(first.find((p) => p.name === 'repoA')!.sessions).toEqual([])

    const second = await buildProjects(cfg, [makeLive({ id: 's1', cwd: repoPath })])
    expect(second.find((p) => p.name === 'repoA')!.sessions.map((s) => s.id)).toEqual(['s1'])

    const third = await buildProjects(cfg, [])
    expect(third.find((p) => p.name === 'repoA')!.sessions).toEqual([])
  })
})

// ── buildProjects: history merge + sorting ───────────────────────────────────────

describe('buildProjects — history merge & sorting', { timeout: 30_000 }, () => {
  it('adds history-only cwds as projects and sets lastActiveMs on discovered repos', async () => {
    await makeRepo(path.join(tmp, 'repoA'), 'main')
    const repoPath = path.join(tmp, 'repoA')
    const histDir = await fs.mkdtemp(path.join(os.tmpdir(), 'projtest-hist-'))
    try {
      mockListSessions.mockResolvedValue([
        { id: 'h1', cwd: repoPath, project: 'repoA', mtimeMs: 5000, preview: '' },
        { id: 'h2', cwd: repoPath, project: 'repoA', mtimeMs: 9000, preview: '' },
        { id: 'h3', cwd: histDir, project: path.basename(histDir), mtimeMs: 3000, preview: '' },
        { id: 'h4', cwd: '', project: 'unknown', mtimeMs: 1, preview: '' },
      ])

      const cfg = makeCfg({ projectRoots: [tmp], projectScanDepth: 2, projectDirtyCheck: false })
      const out = await buildProjects(cfg, [])

      const repoA = out.find((p) => p.name === 'repoA')!
      expect(repoA.lastActiveMs).toBe(9000) // newest of h1/h2

      const hist = out.find((p) => p.path === histDir)!
      expect(hist).toBeDefined()
      expect(hist.isGit).toBe(false)
      expect(hist.lastActiveMs).toBe(3000)

      expect(out.some((p) => p.path === '')).toBe(false) // empty cwd ignored
      expect(out[0]!.name).toBe('repoA') // 9000 > 3000
    } finally {
      await fs.rm(histDir, { recursive: true, force: true })
    }
  })

  it('drops non-git parent folders that merely contain other projects', async () => {
    // Running a session in ~/Documents once puts it in history; it must not be
    // listed as a project when it only contains real repos (parent folder).
    const repoPath = path.join(tmp, 'repoA')
    await makeRepo(repoPath, 'main')
    mockListSessions.mockResolvedValue([
      { id: 'h1', cwd: tmp, project: path.basename(tmp), mtimeMs: 2000, preview: '' },
      { id: 'h2', cwd: repoPath, project: 'repoA', mtimeMs: 1000, preview: '' },
    ])
    const cfg = makeCfg({ projectRoots: [tmp], projectScanDepth: 2, projectDirtyCheck: false })

    const out = await buildProjects(cfg, [])

    expect(out.some((p) => p.path === tmp)).toBe(false) // parent folder dropped
    expect(out.some((p) => p.path === repoPath)).toBe(true)
  })

  it('keeps a non-git history project that contains no other project', async () => {
    const standalone = path.join(tmp, 'notes')
    await fs.mkdir(standalone, { recursive: true })
    mockListSessions.mockResolvedValue([
      { id: 'h1', cwd: standalone, project: 'notes', mtimeMs: 2000, preview: '' },
    ])
    const cfg = makeCfg({ projectRoots: [tmp], projectScanDepth: 2, projectDirtyCheck: false })

    const out = await buildProjects(cfg, [])

    expect(out.some((p) => p.path === standalone)).toBe(true)
  })

  it('sorts by lastActiveMs desc, undefined last, name asc tiebreak', async () => {
    await makeRepo(path.join(tmp, 'zzz'), 'main') // no history -> undefined
    await makeRepo(path.join(tmp, 'aaa'), 'main') // no history -> undefined
    await makeRepo(path.join(tmp, 'mmm'), 'main') // has history -> first
    mockListSessions.mockResolvedValue([
      { id: 'h', cwd: path.join(tmp, 'mmm'), project: 'mmm', mtimeMs: 1000, preview: '' },
    ])

    const cfg = makeCfg({ projectRoots: [tmp], projectScanDepth: 2, projectDirtyCheck: false })
    const out = await buildProjects(cfg, [])

    expect(out.map((p) => p.name)).toEqual(['mmm', 'aaa', 'zzz'])
  })
})

// ── buildProjects: in-flight promise deduplication (Finding 4) ─────────────────────

describe('buildProjects — concurrent cache-miss deduplication', { timeout: 30_000 }, () => {
  it('two concurrent cold-cache calls return consistent results (no corrupt state)', async () => {
    await makeRepo(path.join(tmp, 'repoA'), 'main')
    await makeRepo(path.join(tmp, 'repoB'), 'dev')
    const cfg = makeCfg({ projectRoots: [tmp], projectScanDepth: 2, projectDirtyCheck: false })

    // Both calls start concurrently while cache is cold.
    const [a, b] = await Promise.all([buildProjects(cfg, []), buildProjects(cfg, [])])

    const aNames = a.map((p) => p.name).sort()
    const bNames = b.map((p) => p.name).sort()
    expect(aNames).toEqual(['repoA', 'repoB'])
    expect(bNames).toEqual(aNames)
  })

  it('second concurrent call gets the same result as the first (in-flight reuse)', async () => {
    await makeRepo(path.join(tmp, 'repoA'), 'main')
    const cfg = makeCfg({
      projectRoots: [tmp],
      projectScanDepth: 2,
      projectDirtyCheck: false,
      projectScanTtlMs: 60_000,
    })

    // Start first call, immediately start second — second should share the in-flight promise.
    const first = buildProjects(cfg, [])
    const second = buildProjects(cfg, []) // cache still cold at this synchronous point
    const [r1, r2] = await Promise.all([first, second])
    expect(r1.map((p) => p.name)).toContain('repoA')
    expect(r2.map((p) => p.name)).toEqual(r1.map((p) => p.name))
  })
})

// ── buildProjects: dirty check (real git, when available) ─────────────────────────

describe('buildProjects — dirty check', { timeout: 30_000 }, () => {
  it('computes dirty via git status only when projectDirtyCheck is true', async () => {
    if (!(await gitAvailable())) return // git not installed -> skip
    const repo = path.join(tmp, 'gitrepo')
    await fs.mkdir(repo, { recursive: true })
    await execFileP('git', ['init', '-q'], { cwd: repo })
    await execFileP('git', ['config', 'user.email', 't@t.local'], { cwd: repo })
    await execFileP('git', ['config', 'user.name', 'tester'], { cwd: repo })

    const cfg = makeCfg({ projectRoots: [tmp], projectScanDepth: 2, projectDirtyCheck: true })

    // clean repo -> dirty false
    let out = await buildProjects(cfg, [])
    const clean = out.find((p) => p.name === 'gitrepo')!
    expect(clean.dirty).toBe(false)
    expect(clean.branch).toBeTruthy()

    // untracked file -> dirty true
    await fs.writeFile(path.join(repo, 'a.txt'), 'hi')
    _clearProjectCache()
    out = await buildProjects(cfg, [])
    expect(out.find((p) => p.name === 'gitrepo')!.dirty).toBe(true)
  })

  it('never spawns git (dirty undefined) when projectDirtyCheck is false', async () => {
    await makeRepo(path.join(tmp, 'repoA'), 'main')
    const cfg = makeCfg({ projectRoots: [tmp], projectScanDepth: 2, projectDirtyCheck: false })
    const out = await buildProjects(cfg, [])
    expect(out.find((p) => p.name === 'repoA')!.dirty).toBeUndefined()
  })
})

// ── W3(a) sync chip — ahead/behind vs upstream + last-commit time ──────────────

describe('buildProjects — sync fields (W3 a)', { timeout: 30_000 }, () => {
  async function commit(cwd: string, file: string, msg: string): Promise<void> {
    await fs.writeFile(path.join(cwd, file), `${file}\n`)
    await execFileP('git', ['add', '.'], { cwd })
    await execFileP('git', ['commit', '-q', '-m', msg], { cwd })
  }

  async function initRepo(cwd: string): Promise<void> {
    await fs.mkdir(cwd, { recursive: true })
    await execFileP('git', ['init', '-q', '-b', 'main'], { cwd })
    await execFileP('git', ['config', 'user.email', 't@t.local'], { cwd })
    await execFileP('git', ['config', 'user.name', 'tester'], { cwd })
    await execFileP('git', ['config', 'commit.gpgsign', 'false'], { cwd })
  }

  it('reports ahead/behind vs upstream and lastCommitMs when dirtyCheck is on', async () => {
    if (!(await gitAvailable())) return

    // Upstream repo with one commit; clone it so the clone's main tracks origin/main.
    const upstream = path.join(tmp, 'upstream')
    await initRepo(upstream)
    await commit(upstream, 'a.txt', 'first')

    const cloneRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'projtest-clone-'))
    const clone = path.join(cloneRoot, 'clone')
    await execFileP('git', ['clone', '-q', upstream, clone])
    await execFileP('git', ['config', 'user.email', 't@t.local'], { cwd: clone })
    await execFileP('git', ['config', 'user.name', 'tester'], { cwd: clone })
    await execFileP('git', ['config', 'commit.gpgsign', 'false'], { cwd: clone })

    // Diverge: 1 local commit ahead, then 1 upstream commit fetched → 1 behind.
    await commit(clone, 'local.txt', 'local ahead')
    await commit(upstream, 'b.txt', 'second upstream')
    await execFileP('git', ['fetch', '-q'], { cwd: clone })

    const cfg = makeCfg({ projectRoots: [cloneRoot], projectScanDepth: 2, projectDirtyCheck: true })
    const out = await buildProjects(cfg, [])
    const proj = out.find((p) => p.name === 'clone')!
    expect(proj.ahead).toBe(1)
    expect(proj.behind).toBe(1)
    expect(typeof proj.lastCommitMs).toBe('number')
    expect(proj.lastCommitMs!).toBeGreaterThan(0)

    await fs.rm(cloneRoot, { recursive: true, force: true })
  })

  it('leaves ahead/behind undefined for a repo with no upstream (no throw)', async () => {
    if (!(await gitAvailable())) return
    const repo = path.join(tmp, 'noupstream')
    await initRepo(repo)
    await commit(repo, 'a.txt', 'only')

    const cfg = makeCfg({ projectRoots: [tmp], projectScanDepth: 2, projectDirtyCheck: true })
    const out = await buildProjects(cfg, [])
    const proj = out.find((p) => p.name === 'noupstream')!
    expect(proj.ahead).toBeUndefined()
    expect(proj.behind).toBeUndefined()
    // lastCommitMs still resolves — HEAD has a commit even without an upstream.
    expect(typeof proj.lastCommitMs).toBe('number')
  })

  it('skips sync entirely (all undefined) when projectDirtyCheck is false', async () => {
    if (!(await gitAvailable())) return
    const repo = path.join(tmp, 'skipsync')
    await initRepo(repo)
    await commit(repo, 'a.txt', 'only')

    const cfg = makeCfg({ projectRoots: [tmp], projectScanDepth: 2, projectDirtyCheck: false })
    const out = await buildProjects(cfg, [])
    const proj = out.find((p) => p.name === 'skipsync')!
    expect(proj.ahead).toBeUndefined()
    expect(proj.behind).toBeUndefined()
    expect(proj.lastCommitMs).toBeUndefined()
  })
})

// ── buildProjectDetail ───────────────────────────────────────────────────────────

describe('buildProjectDetail', { timeout: 30_000 }, () => {
  it('returns null for a relative path', async () => {
    expect(await buildProjectDetail(makeCfg({}), 'relative/dir', [])).toBeNull()
  })

  it('returns null for a non-existent path', async () => {
    expect(await buildProjectDetail(makeCfg({}), path.join(tmp, 'nope'), [])).toBeNull()
  })

  it('returns null when the path is a file', async () => {
    const f = path.join(tmp, 'file.txt')
    await fs.writeFile(f, 'x')
    expect(await buildProjectDetail(makeCfg({}), f, [])).toBeNull()
  })

  it('reports a non-git directory with no worktrees', async () => {
    const dir = path.join(tmp, 'plain')
    await fs.mkdir(dir)
    const d = await buildProjectDetail(makeCfg({}), dir, [])
    expect(d).not.toBeNull()
    expect(d!.isGit).toBe(false)
    expect(d!.worktrees).toEqual([])
    expect(d!.name).toBe('plain')
  })

  it('reads the branch for a git repo (worktrees best-effort)', async () => {
    const dir = path.join(tmp, 'repo')
    await makeRepo(dir, 'develop')
    const d = await buildProjectDetail(makeCfg({}), dir, [])
    expect(d!.isGit).toBe(true)
    expect(d!.branch).toBe('develop')
    expect(Array.isArray(d!.worktrees)).toBe(true) // real `git` fails on the fake .git → []
  })

  it('merges running sessions whose cwd is the project or under it', async () => {
    const dir = path.join(tmp, 'repo')
    await fs.mkdir(dir)
    const live = [
      makeLive({ id: 'a', cwd: dir, status: 'working' }),
      makeLive({ id: 'b', cwd: path.join(dir, 'sub') }),
      makeLive({ id: 'c', cwd: path.join(tmp, 'other') }),
    ]
    const d = await buildProjectDetail(makeCfg({}), dir, live)
    expect(d!.sessions.map((s) => s.id).sort()).toEqual(['a', 'b'])
  })

  it('includes CLAUDE.md content when present', async () => {
    const dir = path.join(tmp, 'withmd')
    await fs.mkdir(dir)
    await fs.writeFile(path.join(dir, 'CLAUDE.md'), '# Project\nbe excellent')
    const d = await buildProjectDetail(makeCfg({}), dir, [])
    expect(d!.hasClaudeMd).toBe(true)
    expect(d!.claudeMd).toContain('be excellent')
  })

  it('reports no CLAUDE.md when absent', async () => {
    const dir = path.join(tmp, 'nomd')
    await fs.mkdir(dir)
    const d = await buildProjectDetail(makeCfg({}), dir, [])
    expect(d!.hasClaudeMd).toBe(false)
    expect(d!.claudeMd).toBeUndefined()
  })

  it('lists worktrees for a real git repo (gated on git)', async () => {
    if (!(await gitAvailable())) return
    await fs.mkdir(path.join(tmp, 'realrepo'))
    // git canonicalises paths (/var → /private/var on macOS), so pass the
    // realpath — real project paths from discovery are already canonical.
    const dir = await fs.realpath(path.join(tmp, 'realrepo'))
    await execFileP('git', ['init', '-q', '-b', 'main'], { cwd: dir })
    await execFileP('git', ['config', 'user.email', 't@t'], { cwd: dir })
    await execFileP('git', ['config', 'user.name', 't'], { cwd: dir })
    await fs.writeFile(path.join(dir, 'f.txt'), 'hi')
    await execFileP('git', ['add', '.'], { cwd: dir })
    await execFileP('git', ['commit', '-q', '-m', 'init'], { cwd: dir })

    const d = await buildProjectDetail(makeCfg({ projectDirtyCheck: true }), dir, [])
    expect(d!.isGit).toBe(true)
    expect(d!.branch).toBe('main')
    expect(d!.worktrees.length).toBeGreaterThanOrEqual(1)
    expect(d!.worktrees[0]).toMatchObject({ isMain: true, isCurrent: true, branch: 'main' })
    expect(d!.dirty).toBe(false)
  })
})

// ── w6/G1 sync state on the detail view ────────────────────────────────────────
// The panel's whole point is to be trusted, so these tests pin the *absence* of
// numbers as hard as their presence: "no upstream" must never look like "in sync".

// Each case spawns 6–12 sequential `git` processes; vitest's 5 s default is not a
// budget for that once the suite runs these files in parallel.
describe('buildProjectDetail — sync state (w6 G1)', { timeout: 30_000 }, () => {
  async function commit(cwd: string, file: string, msg: string): Promise<void> {
    await fs.writeFile(path.join(cwd, file), `${file}\n`)
    await execFileP('git', ['add', '.'], { cwd })
    await execFileP('git', ['commit', '-q', '-m', msg], { cwd })
  }

  async function initRepo(cwd: string): Promise<void> {
    await fs.mkdir(cwd, { recursive: true })
    await execFileP('git', ['init', '-q', '-b', 'main'], { cwd })
    await execFileP('git', ['config', 'user.email', 't@t.local'], { cwd })
    await execFileP('git', ['config', 'user.name', 'tester'], { cwd })
    await execFileP('git', ['config', 'commit.gpgsign', 'false'], { cwd })
  }

  it('reports upstream name, ahead and a fetch timestamp for a tracking clone', async () => {
    if (!(await gitAvailable())) return

    const upstream = path.join(tmp, 'up-detail')
    await initRepo(upstream)
    await commit(upstream, 'a.txt', 'first')

    const cloneRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'projtest-syncdetail-'))
    const clone = path.join(cloneRoot, 'clone')
    await execFileP('git', ['clone', '-q', upstream, clone])
    await execFileP('git', ['config', 'user.email', 't@t.local'], { cwd: clone })
    await execFileP('git', ['config', 'user.name', 'tester'], { cwd: clone })
    await execFileP('git', ['config', 'commit.gpgsign', 'false'], { cwd: clone })

    await commit(clone, 'local.txt', 'local ahead')
    await execFileP('git', ['fetch', '-q'], { cwd: clone })

    const cfg = makeCfg({ projectDirtyCheck: true })
    const d = await buildProjectDetail(cfg, clone, [])

    expect(d!.sync).toBeDefined()
    expect(d!.sync!.upstream).toBe('origin/main')
    expect(d!.sync!.ahead).toBe(1)
    expect(d!.sync!.behind).toBe(0)
    expect(d!.sync!.detached).not.toBe(true)
    expect(typeof d!.sync!.lastFetchMs).toBe('number')

    await fs.rm(cloneRoot, { recursive: true, force: true })
  })

  it('leaves upstream AND ahead/behind undefined when the branch tracks nothing', async () => {
    if (!(await gitAvailable())) return
    const repo = path.join(tmp, 'g1-noupstream')
    await initRepo(repo)
    await commit(repo, 'a.txt', 'only')

    const d = await buildProjectDetail(makeCfg({}), repo, [])

    // All three together — a UI that sees ahead===undefined must be able to tell
    // "nothing to compare against" from "nothing to push".
    expect(d!.sync!.upstream).toBeUndefined()
    expect(d!.sync!.ahead).toBeUndefined()
    expect(d!.sync!.behind).toBeUndefined()
  })

  it('never invents a fetch time for a repo that was never fetched', async () => {
    if (!(await gitAvailable())) return
    const repo = path.join(tmp, 'g1-nofetch')
    await initRepo(repo)
    await commit(repo, 'a.txt', 'only')

    const d = await buildProjectDetail(makeCfg({}), repo, [])
    expect(d!.sync!.lastFetchMs).toBeUndefined()
  })

  it('flags a detached HEAD and reports no branch or ahead/behind', async () => {
    if (!(await gitAvailable())) return
    const repo = path.join(tmp, 'g1-detached')
    await initRepo(repo)
    await commit(repo, 'a.txt', 'first')
    await commit(repo, 'b.txt', 'second')
    const { stdout } = await execFileP('git', ['rev-parse', 'HEAD~1'], { cwd: repo })
    await execFileP('git', ['checkout', '-q', stdout.trim()], { cwd: repo })

    const d = await buildProjectDetail(makeCfg({}), repo, [])
    expect(d!.sync!.detached).toBe(true)
    expect(d!.branch).toBeUndefined()
    expect(d!.sync!.ahead).toBeUndefined()
    expect(d!.sync!.behind).toBeUndefined()
    // With no branch name the short sha is the only identity the panel can show.
    // Read out of the .git/HEAD text already loaded for the detached test — no
    // extra git call.
    expect(d!.sync!.head).toBe(stdout.trim().slice(0, 7))
  })

  it('does not report a head sha when HEAD is on a branch', async () => {
    if (!(await gitAvailable())) return
    const repo = path.join(tmp, 'g1-attached-head')
    await initRepo(repo)
    await commit(repo, 'a.txt', 'only')

    const d = await buildProjectDetail(makeCfg({}), repo, [])
    expect(d!.sync!.detached).not.toBe(true)
    expect(d!.sync!.head).toBeUndefined()
  })

  it('counts dirty files instead of only flagging them', async () => {
    if (!(await gitAvailable())) return
    const repo = path.join(tmp, 'g1-dirty')
    await initRepo(repo)
    await commit(repo, 'a.txt', 'first')
    await fs.writeFile(path.join(repo, 'a.txt'), 'changed\n')
    await fs.writeFile(path.join(repo, 'new.txt'), 'new\n')

    const d = await buildProjectDetail(makeCfg({ projectDirtyCheck: true }), repo, [])
    expect(d!.dirtyCount).toBe(2)
    expect(d!.dirty).toBe(true) // legacy field the mobile clients decode — still there
  })

  it('omits sync entirely for a non-git directory', async () => {
    const plain = path.join(tmp, 'g1-plain')
    await fs.mkdir(plain, { recursive: true })

    const d = await buildProjectDetail(makeCfg({ projectDirtyCheck: true }), plain, [])
    expect(d!.isGit).toBe(false)
    expect(d!.sync).toBeUndefined()
    expect(d!.dirtyCount).toBeUndefined()
  })
})

// ── w6/G6 concurrent-probe coalescing ─────────────────────────────────────────
// N devices watching one repo must cost ONE probe, not N. Deliberately in-flight
// only: nothing is cached across time, so the numbers can never go stale.

describe('buildProjectDetail — coalescing (w6 G6)', { timeout: 30_000 }, () => {
  it('serves concurrent probes of the same repo from one in-flight promise', async () => {
    if (!(await gitAvailable())) return
    const repo = path.join(tmp, 'g6-coalesce')
    await fs.mkdir(repo, { recursive: true })
    await execFileP('git', ['init', '-q', '-b', 'main'], { cwd: repo })

    const cfg = makeCfg({ projectDirtyCheck: true })
    const [a, b, c] = await Promise.all([
      buildProjectDetail(cfg, repo, []),
      buildProjectDetail(cfg, repo, []),
      buildProjectDetail(cfg, repo, []),
    ])
    expect(a).toBe(b) // same object identity ⇒ one probe served all three
    expect(b).toBe(c)
  })

  it('re-probes on the next call — the entry is dropped once it settles', async () => {
    if (!(await gitAvailable())) return
    const repo = path.join(tmp, 'g6-fresh')
    await fs.mkdir(repo, { recursive: true })
    await execFileP('git', ['init', '-q', '-b', 'main'], { cwd: repo })

    const cfg = makeCfg({ projectDirtyCheck: true })
    const first = await buildProjectDetail(cfg, repo, [])
    expect(first!.dirtyCount).toBe(0)

    await fs.writeFile(path.join(repo, 'new.txt'), 'x\n')
    const second = await buildProjectDetail(cfg, repo, [])
    expect(second).not.toBe(first) // not a time-based cache
    expect(second!.dirtyCount).toBe(1) // and it sees the change immediately
  })

  it('does not let two different repos share one probe', async () => {
    if (!(await gitAvailable())) return
    const one = path.join(tmp, 'g6-one')
    const two = path.join(tmp, 'g6-two')
    await fs.mkdir(one, { recursive: true })
    await fs.mkdir(two, { recursive: true })

    const cfg = makeCfg({})
    const [a, b] = await Promise.all([
      buildProjectDetail(cfg, one, []),
      buildProjectDetail(cfg, two, []),
    ])
    expect(a!.path).toBe(one)
    expect(b!.path).toBe(two)
  })
})

// ── w6/G7 per-worktree state ──────────────────────────────────────────────────

describe('buildWorktreeState (w6 G7)', { timeout: 30_000 }, () => {
  async function initRepo(cwd: string): Promise<void> {
    await fs.mkdir(cwd, { recursive: true })
    await execFileP('git', ['init', '-q', '-b', 'main'], { cwd })
    await execFileP('git', ['config', 'user.email', 't@t.local'], { cwd })
    await execFileP('git', ['config', 'user.name', 'tester'], { cwd })
    await execFileP('git', ['config', 'commit.gpgsign', 'false'], { cwd })
  }

  it('reads a LINKED worktree, where .git is a file rather than a directory', async () => {
    if (!(await gitAvailable())) return
    const repo = path.join(tmp, 'g7-main')
    await initRepo(repo)
    await fs.writeFile(path.join(repo, 'a.txt'), 'a\n')
    await execFileP('git', ['add', '.'], { cwd: repo })
    await execFileP('git', ['commit', '-q', '-m', 'first'], { cwd: repo })

    const wtPath = path.join(repo, '.claude', 'worktrees', 'feature')
    await execFileP('git', ['worktree', 'add', '-q', '-b', 'feature', wtPath], { cwd: repo })

    // The pre-G1 reader assumed <repo>/.git was a directory and returned nothing here.
    const dotGit = await fs.stat(path.join(wtPath, '.git'))
    expect(dotGit.isDirectory()).toBe(false)

    const state = await buildWorktreeState(makeCfg({ projectDirtyCheck: true }), wtPath)
    expect(state!.path).toBe(wtPath)
    expect(state!.branch).toBe('feature')
    expect(state!.dirtyCount).toBe(0)
    // A fresh worktree branch tracks nothing — it must say so, not look settled.
    expect(state!.sync!.upstream).toBeUndefined()
    expect(state!.sync!.ahead).toBeUndefined()
  })

  it('returns null for a directory that is not a git repo', async () => {
    const plain = path.join(tmp, 'g7-plain')
    await fs.mkdir(plain, { recursive: true })
    expect(await buildWorktreeState(makeCfg({}), plain)).toBeNull()
  })

  it('returns null for a relative path and for one that does not exist', async () => {
    expect(await buildWorktreeState(makeCfg({}), 'relative/path')).toBeNull()
    expect(await buildWorktreeState(makeCfg({}), path.join(tmp, 'g7-missing'))).toBeNull()
  })
})
