/**
 * T-server-wire — integration tests for the B3 worktree-create and B1 diff routes.
 *
 * Covers (against a real startServer):
 *   - POST /projects/worktree → Origin guard (403), WORKTREE_ENABLED=0 (403),
 *                               invalid branch (400), missing fields (400),
 *                               real creation in a temp git repo (git-gated)
 *   - GET  /projects/diff      → 400 missing path, 404 non-git path, structured
 *                               DiffResult for a real repo with verbatim content
 */

import net from 'node:net'
import os from 'node:os'
import path from 'node:path'
import fs from 'node:fs/promises'
import { execFileSync } from 'node:child_process'

import { afterEach, describe, expect, it } from 'vitest'

import { loadConfig } from '../../src/config.js'
import { startServer } from '../../src/server.js'
import type { DiffResult } from '../../src/types.js'

const GIT_AVAILABLE = (() => {
  try {
    execFileSync('git', ['--version'], { stdio: 'ignore' })
    return true
  } catch {
    return false
  }
})()
const itGit = GIT_AVAILABLE ? it : it.skip

function getFreePort(): Promise<number> {
  return new Promise((resolve, reject) => {
    const srv = net.createServer()
    srv.listen(0, '127.0.0.1', () => {
      const addr = srv.address()
      if (addr === null || typeof addr === 'string') {
        srv.close()
        reject(new Error('bad addr'))
        return
      }
      const port = addr.port
      srv.close(() => resolve(port))
    })
    srv.on('error', reject)
  })
}

const handles: { close(): Promise<void> }[] = []
const tmpDirs: string[] = []

async function spawnServer(
  overrides: Record<string, string | undefined> = {},
): Promise<{ port: number; origin: string }> {
  const port = await getFreePort()
  const cfg = loadConfig({
    PORT: String(port),
    BIND_HOST: '127.0.0.1',
    SHELL_PATH: process.env['SHELL'] ?? '/bin/zsh',
    ALLOWED_ORIGINS: `http://127.0.0.1:${port}`,
    USE_TMUX: '0',
    IDLE_TTL: '86400',
    ...overrides,
  })
  const handle = startServer(cfg)
  handles.push(handle)
  await new Promise<void>((r) => setTimeout(r, 80))
  return { port, origin: `http://127.0.0.1:${port}` }
}

/** Create a temp git repo with one committed file. Returns its absolute path. */
async function makeRealRepo(): Promise<string> {
  const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'webterm-wt-'))
  tmpDirs.push(dir)
  const git = (args: string[]): void => {
    execFileSync('git', args, { cwd: dir, stdio: 'ignore' })
  }
  git(['init'])
  git(['config', 'user.email', 'test@localhost'])
  git(['config', 'user.name', 'Test'])
  git(['config', 'commit.gpgsign', 'false'])
  await fs.writeFile(path.join(dir, 'file.txt'), 'line1\nline2\n', 'utf8')
  git(['add', '.'])
  git(['commit', '-m', 'init'])
  return dir
}

/** Temp git repo on `main` with a `feature` branch that adds one committed file
 *  containing a would-be XSS payload. Returns its absolute path. */
async function makeTwoBranchRepo(): Promise<string> {
  const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'webterm-base-'))
  tmpDirs.push(dir)
  const git = (args: string[]): void => {
    execFileSync('git', args, { cwd: dir, stdio: 'ignore' })
  }
  git(['init', '-b', 'main'])
  git(['config', 'user.email', 'test@localhost'])
  git(['config', 'user.name', 'Test'])
  git(['config', 'commit.gpgsign', 'false'])
  await fs.writeFile(path.join(dir, 'base.txt'), 'base\n', 'utf8')
  git(['add', '.'])
  git(['commit', '-m', 'base on main'])
  git(['checkout', '-b', 'feature'])
  await fs.writeFile(path.join(dir, 'feat.txt'), '<script>x</script>\n', 'utf8')
  git(['add', '.'])
  git(['commit', '-m', 'feature work'])
  return dir
}

afterEach(async () => {
  while (handles.length > 0) await handles.pop()?.close()
  for (const d of tmpDirs.splice(0)) await fs.rm(d, { recursive: true, force: true }).catch(() => undefined)
  await new Promise<void>((r) => setTimeout(r, 30))
})

// ── POST /projects/worktree ──────────────────────────────────────────────────────

describe('POST /projects/worktree (B3)', { timeout: 30_000 }, () => {
  it('rejects a foreign Origin with 403 (SEC-C3)', async () => {
    const { port } = await spawnServer()
    const res = await fetch(`http://127.0.0.1:${port}/projects/worktree`, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json', Origin: 'http://evil.example' },
      body: JSON.stringify({ path: '/tmp/x', branch: 'feature' }),
    })
    expect(res.status).toBe(403)
  })

  it('rejects a missing Origin with 403', async () => {
    const { port } = await spawnServer()
    const res = await fetch(`http://127.0.0.1:${port}/projects/worktree`, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ path: '/tmp/x', branch: 'feature' }),
    })
    expect(res.status).toBe(403)
  })

  it('returns 403 when WORKTREE_ENABLED=0', async () => {
    const { port, origin } = await spawnServer({ WORKTREE_ENABLED: '0' })
    const res = await fetch(`http://127.0.0.1:${port}/projects/worktree`, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json', Origin: origin },
      body: JSON.stringify({ path: '/tmp/x', branch: 'feature' }),
    })
    expect(res.status).toBe(403)
  })

  it('rejects an invalid branch name with 400 (no git executed, SEC-H2)', async () => {
    const { port, origin } = await spawnServer()
    const res = await fetch(`http://127.0.0.1:${port}/projects/worktree`, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json', Origin: origin },
      body: JSON.stringify({ path: '/tmp/x', branch: '../evil' }),
    })
    expect(res.status).toBe(400)
  })

  it('rejects a missing branch field with 400', async () => {
    const { port, origin } = await spawnServer()
    const res = await fetch(`http://127.0.0.1:${port}/projects/worktree`, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json', Origin: origin },
      body: JSON.stringify({ path: '/tmp/x' }),
    })
    expect(res.status).toBe(400)
  })

  itGit('creates a worktree in a real git repo and returns its path', async () => {
    const repo = await makeRealRepo()
    const root = await fs.mkdtemp(path.join(os.tmpdir(), 'webterm-wtroot-'))
    tmpDirs.push(root)
    const { port, origin } = await spawnServer({ WORKTREE_ROOT: root })

    const res = await fetch(`http://127.0.0.1:${port}/projects/worktree`, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json', Origin: origin },
      body: JSON.stringify({ path: repo, branch: 'feature-x' }),
    })
    expect(res.status).toBe(200)
    const body = (await res.json()) as { ok: boolean; path?: string; branch?: string }
    expect(body.ok).toBe(true)
    expect(body.branch).toBe('feature-x')
    expect(typeof body.path).toBe('string')
    // The created dir actually exists and is a worktree.
    const list = execFileSync('git', ['worktree', 'list'], { cwd: repo }).toString()
    expect(list).toContain(body.path!)
  })
})

// ── DELETE /projects/worktree (W4 remove) ─────────────────────────────────────────

/** Create a worktree via the POST route; returns its git path. */
async function createWorktreeViaRoute(
  port: number,
  origin: string,
  repo: string,
  branch: string,
): Promise<string> {
  const res = await fetch(`http://127.0.0.1:${port}/projects/worktree`, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json', Origin: origin },
    body: JSON.stringify({ path: repo, branch }),
  })
  expect(res.status).toBe(200)
  const body = (await res.json()) as { ok: boolean; path?: string }
  expect(body.ok).toBe(true)
  return body.path as string
}

describe('DELETE /projects/worktree (W4)', { timeout: 30_000 }, () => {
  it('rejects a foreign Origin with 403 (SEC-C3)', async () => {
    const { port } = await spawnServer()
    const res = await fetch(`http://127.0.0.1:${port}/projects/worktree`, {
      method: 'DELETE',
      headers: { 'Content-Type': 'application/json', Origin: 'http://evil.example' },
      body: JSON.stringify({ path: '/tmp/x', worktreePath: '/tmp/x/wt' }),
    })
    expect(res.status).toBe(403)
  })

  it('rejects a missing Origin with 403', async () => {
    const { port } = await spawnServer()
    const res = await fetch(`http://127.0.0.1:${port}/projects/worktree`, {
      method: 'DELETE',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ path: '/tmp/x', worktreePath: '/tmp/x/wt' }),
    })
    expect(res.status).toBe(403)
  })

  it('returns 403 when WORKTREE_ENABLED=0', async () => {
    const { port, origin } = await spawnServer({ WORKTREE_ENABLED: '0' })
    const res = await fetch(`http://127.0.0.1:${port}/projects/worktree`, {
      method: 'DELETE',
      headers: { 'Content-Type': 'application/json', Origin: origin },
      body: JSON.stringify({ path: '/tmp/x', worktreePath: '/tmp/x/wt' }),
    })
    expect(res.status).toBe(403)
  })

  it('returns 400 when worktreePath is missing', async () => {
    const { port, origin } = await spawnServer()
    const res = await fetch(`http://127.0.0.1:${port}/projects/worktree`, {
      method: 'DELETE',
      headers: { 'Content-Type': 'application/json', Origin: origin },
      body: JSON.stringify({ path: '/tmp/x' }),
    })
    expect(res.status).toBe(400)
  })

  itGit('refuses to remove the main worktree with 400', async () => {
    const repo = await makeRealRepo()
    const { port, origin } = await spawnServer()
    const res = await fetch(`http://127.0.0.1:${port}/projects/worktree`, {
      method: 'DELETE',
      headers: { 'Content-Type': 'application/json', Origin: origin },
      body: JSON.stringify({ path: repo, worktreePath: repo }),
    })
    expect(res.status).toBe(400)
  })

  itGit('removes a clean linked worktree and returns ok (200)', async () => {
    const repo = await makeRealRepo()
    const root = await fs.mkdtemp(path.join(os.tmpdir(), 'webterm-rmroot-'))
    tmpDirs.push(root)
    const { port, origin } = await spawnServer({ WORKTREE_ROOT: root })
    const wt = await createWorktreeViaRoute(port, origin, repo, 'to-remove')
    expect(execFileSync('git', ['worktree', 'list'], { cwd: repo }).toString()).toContain(wt)

    const res = await fetch(`http://127.0.0.1:${port}/projects/worktree`, {
      method: 'DELETE',
      headers: { 'Content-Type': 'application/json', Origin: origin },
      body: JSON.stringify({ path: repo, worktreePath: wt }),
    })
    expect(res.status).toBe(200)
    const body = (await res.json()) as { ok: boolean; path?: string }
    expect(body.ok).toBe(true)
    expect(execFileSync('git', ['worktree', 'list'], { cwd: repo }).toString()).not.toContain(wt)
  })

  itGit('refuses a dirty worktree (409), then removes it with force (200)', async () => {
    const repo = await makeRealRepo()
    const root = await fs.mkdtemp(path.join(os.tmpdir(), 'webterm-dirtyroot-'))
    tmpDirs.push(root)
    const { port, origin } = await spawnServer({ WORKTREE_ROOT: root })
    const wt = await createWorktreeViaRoute(port, origin, repo, 'dirty-one')
    await fs.writeFile(path.join(wt, 'scratch.txt'), 'wip\n', 'utf8')

    const refused = await fetch(`http://127.0.0.1:${port}/projects/worktree`, {
      method: 'DELETE',
      headers: { 'Content-Type': 'application/json', Origin: origin },
      body: JSON.stringify({ path: repo, worktreePath: wt }),
    })
    expect(refused.status).toBe(409)

    const forced = await fetch(`http://127.0.0.1:${port}/projects/worktree`, {
      method: 'DELETE',
      headers: { 'Content-Type': 'application/json', Origin: origin },
      body: JSON.stringify({ path: repo, worktreePath: wt, force: true }),
    })
    expect(forced.status).toBe(200)
    expect(execFileSync('git', ['worktree', 'list'], { cwd: repo }).toString()).not.toContain(wt)
  })
})

// ── POST /projects/worktree/prune (W4) ────────────────────────────────────────────

describe('POST /projects/worktree/prune (W4)', { timeout: 30_000 }, () => {
  it('rejects a foreign Origin with 403', async () => {
    const { port } = await spawnServer()
    const res = await fetch(`http://127.0.0.1:${port}/projects/worktree/prune`, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json', Origin: 'http://evil.example' },
      body: JSON.stringify({ path: '/tmp/x' }),
    })
    expect(res.status).toBe(403)
  })

  it('returns 403 when WORKTREE_ENABLED=0', async () => {
    const { port, origin } = await spawnServer({ WORKTREE_ENABLED: '0' })
    const res = await fetch(`http://127.0.0.1:${port}/projects/worktree/prune`, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json', Origin: origin },
      body: JSON.stringify({ path: '/tmp/x' }),
    })
    expect(res.status).toBe(403)
  })

  it('returns 400 when path is missing', async () => {
    const { port, origin } = await spawnServer()
    const res = await fetch(`http://127.0.0.1:${port}/projects/worktree/prune`, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json', Origin: origin },
      body: JSON.stringify({}),
    })
    expect(res.status).toBe(400)
  })

  itGit('prunes a worktree whose folder was deleted out-of-band (200)', async () => {
    const repo = await makeRealRepo()
    const root = await fs.mkdtemp(path.join(os.tmpdir(), 'webterm-pruneroot-'))
    tmpDirs.push(root)
    const { port, origin } = await spawnServer({ WORKTREE_ROOT: root })
    const wt = await createWorktreeViaRoute(port, origin, repo, 'stale-one')
    await fs.rm(wt, { recursive: true, force: true })

    const res = await fetch(`http://127.0.0.1:${port}/projects/worktree/prune`, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json', Origin: origin },
      body: JSON.stringify({ path: repo }),
    })
    expect(res.status).toBe(200)
    const body = (await res.json()) as { ok: boolean; pruned: string[] }
    expect(body.ok).toBe(true)
    expect(Array.isArray(body.pruned)).toBe(true)
    expect(execFileSync('git', ['worktree', 'list'], { cwd: repo }).toString()).not.toContain(wt)
  })
})

// ── GET /projects/diff ───────────────────────────────────────────────────────────

describe('GET /projects/diff (B1)', { timeout: 30_000 }, () => {
  it('returns 400 when the path query parameter is missing', async () => {
    const { port } = await spawnServer()
    const res = await fetch(`http://127.0.0.1:${port}/projects/diff`)
    expect(res.status).toBe(400)
  })

  it('returns 404 for a non-git directory (SEC-H7)', async () => {
    const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'webterm-nogit-'))
    tmpDirs.push(dir)
    const { port } = await spawnServer()
    const res = await fetch(`http://127.0.0.1:${port}/projects/diff?path=${encodeURIComponent(dir)}`)
    expect(res.status).toBe(404)
  })

  itGit('returns a structured DiffResult with verbatim content (AC-B1.4)', async () => {
    const repo = await makeRealRepo()
    // Introduce a tracked change containing a would-be XSS payload.
    await fs.writeFile(path.join(repo, 'file.txt'), 'line1\n<script>x</script>\n', 'utf8')
    const { port } = await spawnServer()

    const res = await fetch(`http://127.0.0.1:${port}/projects/diff?path=${encodeURIComponent(repo)}`)
    expect(res.status).toBe(200)
    const diff = (await res.json()) as DiffResult
    expect(diff.staged).toBe(false)
    expect(diff.files.length).toBeGreaterThan(0)
    const flat = JSON.stringify(diff)
    // The diff carries the raw text verbatim (the FE renders it inert via textContent).
    expect(flat).toContain('<script>x</script>')
  })

  // ── FR-B1.9: ?base=<rev> whole-branch diff ─────────────────────────────────

  itGit('diffs the whole branch against a base (?base=main), echoing base', async () => {
    const repo = await makeTwoBranchRepo() // HEAD is on `feature`
    const { port } = await spawnServer()

    const res = await fetch(
      `http://127.0.0.1:${port}/projects/diff?path=${encodeURIComponent(repo)}&base=main`,
    )
    expect(res.status).toBe(200)
    const diff = (await res.json()) as DiffResult
    expect(diff.base).toBe('main')
    expect(diff.staged).toBe(false)
    const feat = diff.files.find((f) => f.newPath === 'feat.txt')
    expect(feat).toBeDefined()
    expect(feat?.status).toBe('added')
    expect(JSON.stringify(diff)).toContain('<script>x</script>')
  })

  itGit('rejects a flag-injection base with 400 (?base=-rf)', async () => {
    const repo = await makeTwoBranchRepo()
    const { port } = await spawnServer()
    const res = await fetch(
      `http://127.0.0.1:${port}/projects/diff?path=${encodeURIComponent(repo)}&base=-rf`,
    )
    expect(res.status).toBe(400)
  })

  itGit('rejects a metacharacter base with 400 (?base=main;rm)', async () => {
    const repo = await makeTwoBranchRepo()
    const { port } = await spawnServer()
    const res = await fetch(
      `http://127.0.0.1:${port}/projects/diff?path=${encodeURIComponent(repo)}` +
        `&base=${encodeURIComponent('main; rm -rf /')}`,
    )
    expect(res.status).toBe(400)
  })

  itGit('returns 200 with an empty diff for an unknown base (?base=ghost-branch)', async () => {
    const repo = await makeTwoBranchRepo()
    const { port } = await spawnServer()
    const res = await fetch(
      `http://127.0.0.1:${port}/projects/diff?path=${encodeURIComponent(repo)}&base=ghost-branch`,
    )
    expect(res.status).toBe(200)
    const diff = (await res.json()) as DiffResult
    expect(diff.base).toBe('ghost-branch')
    expect(diff.files).toEqual([])
  })
})
