/**
 * test/http/worktrees-create.test.ts — B3 git worktree creation (N-worktree).
 *
 * Covers the three new exports of src/http/worktrees.ts:
 *   - validateBranchName  (pure: git-ref subset rejection rules, SEC-H2)
 *   - sanitizeBranchForDir (pure: branch → safe single dir segment)
 *   - computeWorktreeDir  (realpath-based containment, SEC-H3/M2)
 *   - createWorktree      (execFile, no shell, structured result, SEC-M10)
 *
 * Pure functions are unit-tested; createWorktree runs against real temporary
 * git repos (no network) — endpoint wiring is exercised separately in
 * T-server-wire's integration suite.
 */

import { describe, it, expect, beforeAll } 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 {
  validateBranchName,
  sanitizeBranchForDir,
  computeWorktreeDir,
  createWorktree,
} from '../../src/http/worktrees.js'

const execFileP = promisify(execFile)
const TIMEOUT_MS = 10000

let gitAvailable = false
beforeAll(async () => {
  try {
    await execFileP('git', ['--version'])
    gitAvailable = true
  } catch {
    gitAvailable = false
  }
})

/** Create a temp git repo with one commit so `git worktree add -b` can run. */
async function makeRepo(): Promise<string> {
  const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'wt-repo-'))
  await execFileP('git', ['init', '-q', '-b', 'main'], { cwd: dir })
  await execFileP('git', ['config', 'user.email', 't@t.local'], { cwd: dir })
  await execFileP('git', ['config', 'user.name', 'tester'], { cwd: dir })
  await execFileP('git', ['commit', '-q', '--allow-empty', '-m', 'init'], { cwd: dir })
  return dir
}

// ── validateBranchName ─────────────────────────────────────────────────────────

describe('validateBranchName', { timeout: 30_000 }, () => {
  it('accepts ordinary branch names', () => {
    for (const ok of ['feature', 'feature/login', 'fix-bug', 'v1.2.3', 'foo_bar', 'a/b/c']) {
      expect(validateBranchName(ok)).toBe(true)
    }
  })

  it('rejects empty and over-long names', () => {
    expect(validateBranchName('')).toBe(false)
    expect(validateBranchName('a'.repeat(251))).toBe(false)
  })

  it('rejects a leading dash (would be parsed as a git flag, SEC-H2)', () => {
    expect(validateBranchName('-rf')).toBe(false)
    expect(validateBranchName('-b')).toBe(false)
  })

  it('rejects path traversal and slash edge cases', () => {
    for (const bad of ['../x', 'foo..bar', '/leading', 'trailing/', 'double//slash']) {
      expect(validateBranchName(bad)).toBe(false)
    }
  })

  it('rejects a trailing .lock', () => {
    expect(validateBranchName('foo.lock')).toBe(false)
  })

  it('rejects whitespace and control characters', () => {
    expect(validateBranchName('has space')).toBe(false)
    expect(validateBranchName('tab\tx')).toBe(false)
    expect(validateBranchName('ctrl\x01x')).toBe(false)
    expect(validateBranchName('null\x00x')).toBe(false)
  })

  it('rejects git-forbidden punctuation ~^:?*[\\ and @{', () => {
    for (const bad of ['a~b', 'a^b', 'a:b', 'a?b', 'a*b', 'a[b', 'back\\slash', 'foo@{1}']) {
      expect(validateBranchName(bad)).toBe(false)
    }
  })
})

// ── sanitizeBranchForDir ────────────────────────────────────────────────────────

describe('sanitizeBranchForDir', { timeout: 30_000 }, () => {
  it('replaces slashes with dashes', () => {
    expect(sanitizeBranchForDir('feature/login')).toBe('feature-login')
    expect(sanitizeBranchForDir('a/b/c')).toBe('a-b-c')
  })

  it('trims leading/trailing dashes', () => {
    expect(sanitizeBranchForDir('/foo/')).toBe('foo')
    expect(sanitizeBranchForDir('-foo-')).toBe('foo')
  })

  it('replaces filesystem-unsafe characters with a dash', () => {
    expect(sanitizeBranchForDir('a b')).toBe('a-b')
    expect(sanitizeBranchForDir('weird$name')).toBe('weird-name')
  })

  it('preserves safe characters (alnum, dot, underscore, dash)', () => {
    expect(sanitizeBranchForDir('v1.2.3_rc')).toBe('v1.2.3_rc')
  })
})

// ── computeWorktreeDir ──────────────────────────────────────────────────────────

describe('computeWorktreeDir', { timeout: 30_000 }, () => {
  it('places the worktree under an explicit root', async () => {
    const root = await fs.mkdtemp(path.join(os.tmpdir(), 'wt-root-'))
    const dir = await computeWorktreeDir('/repo', 'feature', root)
    expect(dir).toBe(path.join(root, 'feature'))
  })

  it('defaults the base to <repo>-worktrees alongside the repo', async () => {
    const repo = await fs.mkdtemp(path.join(os.tmpdir(), 'wt-defbase-'))
    const dir = await computeWorktreeDir(repo, 'foo')
    expect(dir).toBe(path.join(path.dirname(repo), path.basename(repo) + '-worktrees', 'foo'))
  })

  it('throws when the sanitized segment resolves outside the root (.., M2)', async () => {
    const root = await fs.mkdtemp(path.join(os.tmpdir(), 'wt-esc-'))
    await expect(computeWorktreeDir('/repo', '..', root)).rejects.toThrow()
  })

  it('rejects a symlinked candidate that escapes the root after realpath (AC-B3.4)', async () => {
    const root = await fs.mkdtemp(path.join(os.tmpdir(), 'wt-symroot-'))
    const outside = await fs.mkdtemp(path.join(os.tmpdir(), 'wt-outside-'))
    // Pre-plant a symlink at the predicted candidate path pointing outside root.
    await fs.symlink(outside, path.join(root, 'feature'))
    await expect(computeWorktreeDir('/repo', 'feature', root)).rejects.toThrow()
  })
})

// ── createWorktree (real temp repos) ────────────────────────────────────────────

describe('createWorktree', { timeout: 30_000 }, () => {
  it('creates a new worktree + branch and returns its path', async () => {
    if (!gitAvailable) return
    const repo = await makeRepo()
    const res = await createWorktree(repo, 'feature/new', { timeoutMs: TIMEOUT_MS })
    expect(res.ok).toBe(true)
    expect(res.branch).toBe('feature/new')
    expect(res.path).toBeDefined()
    const stat = await fs.stat(res.path as string)
    expect(stat.isDirectory()).toBe(true)
    const { stdout } = await execFileP('git', ['worktree', 'list', '--porcelain'], { cwd: repo })
    expect(stdout).toContain(res.path as string)
    expect(stdout).toContain('branch refs/heads/feature/new')
  })

  it('rejects an invalid branch name with 400 and never runs git', async () => {
    if (!gitAvailable) return
    const repo = await makeRepo()
    const res = await createWorktree(repo, '../evil', { timeoutMs: TIMEOUT_MS })
    expect(res).toMatchObject({ ok: false, status: 400 })
    // No worktree was created (still just the main one).
    const { stdout } = await execFileP('git', ['worktree', 'list'], { cwd: repo })
    expect(stdout.trim().split('\n')).toHaveLength(1)
  })

  it('returns 404 for a non-git directory', async () => {
    const plain = await fs.mkdtemp(path.join(os.tmpdir(), 'wt-plain-'))
    const res = await createWorktree(plain, 'feature', { timeoutMs: TIMEOUT_MS })
    expect(res).toMatchObject({ ok: false, status: 404 })
  })

  it('returns 404 for a non-absolute repo path', async () => {
    const res = await createWorktree('relative/path', 'feature', { timeoutMs: TIMEOUT_MS })
    expect(res).toMatchObject({ ok: false, status: 404 })
  })

  it('returns 409 when the branch already exists', async () => {
    if (!gitAvailable) return
    const repo = await makeRepo()
    const first = await createWorktree(repo, 'dup', { timeoutMs: TIMEOUT_MS })
    expect(first.ok).toBe(true)
    const second = await createWorktree(repo, 'dup', { timeoutMs: TIMEOUT_MS })
    expect(second).toMatchObject({ ok: false, status: 409 })
    expect(second.error).toBeDefined()
    // Error message must not leak raw git stderr (SEC-M10).
    expect(second.error).not.toContain('fatal:')
  })

  it('returns 400 when the computed dir escapes a symlinked root (M2)', async () => {
    if (!gitAvailable) return
    const repo = await makeRepo()
    const root = await fs.mkdtemp(path.join(os.tmpdir(), 'wt-croot-'))
    const outside = await fs.mkdtemp(path.join(os.tmpdir(), 'wt-cout-'))
    await fs.symlink(outside, path.join(root, 'feature'))
    const res = await createWorktree(repo, 'feature', { worktreeRoot: root, timeoutMs: TIMEOUT_MS })
    expect(res).toMatchObject({ ok: false, status: 400 })
  })
})
