import { execFileSync } from 'node:child_process'
import { existsSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { dirname, join } from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'

import { renderChangeScope } from './change-scope.ts'

interface Report {
  formatVersion: number
  repositoryRoot: string
  input: { base: string; head: string }
  resolved: { baseSha: string; headSha: string; mergeBaseSha: string }
  paths: { committed: string[]; staged: string[]; unstaged: string[]; untracked: string[] }
}

interface Fixture {
  container: string
  root: string
}

const fixtureRoots: string[] = []

afterEach(() => {
  for (const root of fixtureRoots.splice(0)) rmSync(root, { recursive: true, force: true })
})

function git(cwd: string, args: string[], input?: string | Buffer): string {
  return execFileSync('git', ['-C', cwd, ...args], {
    encoding: 'utf8',
    env: { ...process.env, LANG: 'C', LC_ALL: 'C' },
    input,
    stdio: ['pipe', 'pipe', 'pipe'],
  }).trim()
}

function gitBytes(cwd: string, args: string[], input?: Buffer): Buffer {
  return execFileSync('git', ['-C', cwd, ...args], {
    env: { ...process.env, LANG: 'C', LC_ALL: 'C' },
    input,
    stdio: ['pipe', 'pipe', 'pipe'],
  })
}

function write(path: string, content: string, mode?: number): void {
  mkdirSync(dirname(path), { recursive: true })
  writeFileSync(path, content, mode === undefined ? undefined : { mode })
}

function fixture(worktreeName = 'worktree'): Fixture {
  const container = mkdtempSync(join(tmpdir(), 'dsh-change-scope-'))
  fixtureRoots.push(container)
  const origin = join(container, 'origin.git')
  const root = join(container, worktreeName)
  const hooks = join(container, 'hooks')
  mkdirSync(hooks)
  git(container, ['init', '--bare', '--initial-branch=master', origin])
  git(container, ['init', '--initial-branch=master', root])
  git(root, ['config', 'user.email', 'change-scope@example.com'])
  git(root, ['config', 'user.name', 'Change Scope Tests'])
  git(root, ['config', 'commit.gpgsign', 'false'])
  git(root, ['config', 'core.hooksPath', hooks])
  write(join(root, 'README.md'), '# Fixture\n')
  git(root, ['add', 'README.md'])
  git(root, ['commit', '-m', 'initial'])
  git(root, ['remote', 'add', 'origin', origin])
  git(root, ['push', '--set-upstream', 'origin', 'master'])
  return { container, root }
}

function commit(root: string, path: string, content: string): string {
  write(join(root, path), content)
  git(root, ['add', '--', path])
  git(root, ['commit', '-m', `add ${path}`])
  return git(root, ['rev-parse', 'HEAD'])
}

function invoke(root: string, args: string[]): string {
  return renderChangeScope(args, root)
}

function jsonReport(root: string, base: string, head?: string): Report {
  const args = ['--base', base]
  if (head !== undefined) args.push('--head', head)
  return JSON.parse(invoke(root, args)) as Report
}

function repositoryState(root: string): Record<string, string> {
  const status = git(root, ['status', '--porcelain=v2', '--branch', '-z'])
  return {
    status,
    head: git(root, ['rev-parse', 'HEAD']),
    refs: git(root, ['for-each-ref', '--format=%(refname) %(objectname)']),
    index: readFileSync(join(root, '.git/index')).toString('base64'),
    config: readFileSync(join(root, '.git/config')).toString('base64'),
  }
}

describe('change-scope', () => {
  it('uses an explicit base on a fresh branch without a same-name remote and after its first push', () => {
    const { root } = fixture()
    git(root, ['switch', '-c', 'feature'])
    git(root, ['branch', '--set-upstream-to=origin/master'])
    const headSha = commit(root, 'feature.txt', 'feature\n')

    const fresh = jsonReport(root, 'origin/master')
    expect(fresh.repositoryRoot).toBe(realpathSync(root))
    expect(fresh.resolved).toEqual({
      baseSha: git(root, ['rev-parse', 'origin/master']),
      headSha,
      mergeBaseSha: git(root, ['rev-parse', 'origin/master']),
    })
    expect(fresh.paths).toEqual({ committed: ['feature.txt'], staged: [], unstaged: [], untracked: [] })
    expect(git(root, ['for-each-ref', '--format=%(refname)', 'refs/remotes/origin/feature'])).toBe('')

    git(root, ['push', '--set-upstream', 'origin', 'feature'])
    const pushed = jsonReport(root, 'origin/master')
    expect(pushed.paths.committed).toEqual(['feature.txt'])
  })

  it.skipIf(process.platform === 'win32')('preserves trailing spaces in the worktree path', () => {
    const { root } = fixture('worktree ')
    const report = jsonReport(root, 'HEAD')

    expect(report.repositoryRoot).toBe(realpathSync(root))
    expect(report.paths).toEqual({ committed: [], staged: [], unstaged: [], untracked: [] })
  })

  it('reports an exact head above a non-master stacked base while dirty paths remain worktree-local', () => {
    const { root } = fixture()
    git(root, ['switch', '-c', 'foundation'])
    const baseSha = commit(root, 'foundation.txt', 'foundation\n')
    git(root, ['switch', '-c', 'topic'])
    const headSha = commit(root, 'topic.txt', 'topic\n')
    commit(root, 'later.txt', 'later\n')
    write(join(root, 'current-worktree.txt'), 'current worktree\n')

    const report = jsonReport(root, 'foundation', headSha)
    expect(report.input).toEqual({ base: 'foundation', head: headSha })
    expect(report.resolved).toEqual({ baseSha, headSha, mergeBaseSha: baseSha })
    expect(report.paths.committed).toEqual(['topic.txt'])
    expect(report.paths.untracked).toEqual(['current-worktree.txt'])
  })

  it('keeps committed, staged, unstaged, and untracked paths independent and does not mutate state', () => {
    const { root } = fixture()
    commit(root, 'unstaged.txt', 'before\n')
    const baseSha = git(root, ['rev-parse', 'HEAD'])
    commit(root, 'committed.txt', 'committed\n')
    write(join(root, 'staged.txt'), 'staged\n')
    write(join(root, 'mixed.txt'), 'staged part\n')
    git(root, ['add', 'staged.txt', 'mixed.txt'])
    write(join(root, 'mixed.txt'), 'staged part\nunstaged part\n')
    write(join(root, 'unstaged.txt'), 'unstaged\n')
    write(join(root, 'untracked.txt'), 'untracked\n')
    const before = repositoryState(root)

    const report = jsonReport(root, baseSha)

    expect(report.paths).toEqual({
      committed: ['committed.txt'],
      staged: ['mixed.txt', 'staged.txt'],
      unstaged: ['mixed.txt', 'unstaged.txt'],
      untracked: ['untracked.txt'],
    })
    expect(repositoryState(root)).toEqual(before)
  })

  it.skipIf(process.platform === 'win32')('does not execute a configured filesystem monitor', () => {
    const { container, root } = fixture()
    const monitor = join(container, 'fsmonitor.sh')
    const sideEffect = `${monitor}.ran`
    write(monitor, '#!/bin/sh\ntouch "$0.ran"\n', 0o755)
    git(root, ['config', 'core.fsmonitor', monitor])

    const report = jsonReport(root, 'HEAD')

    expect(report.paths).toEqual({ committed: [], staged: [], unstaged: [], untracked: [] })
    expect(existsSync(sideEffect)).toBe(false)
  })

  it.skipIf(process.platform === 'win32')('rejects distinct non-UTF-8 Git paths', () => {
    const { root } = fixture()
    const blobSha = git(root, ['hash-object', '-w', '--stdin'], 'content')
    const entry = Buffer.from(`100644 ${blobSha}\t`, 'ascii')
    const firstPath = Buffer.from([0x80])
    const secondPath = Buffer.from([0x81])
    gitBytes(root, ['update-index', '-z', '--index-info'], Buffer.concat([
      entry,
      firstPath,
      Buffer.from([0]),
      entry,
      secondPath,
      Buffer.from([0]),
    ]))
    expect(gitBytes(root, ['diff', '--cached', '--name-only', '-z', '--'])).toEqual(Buffer.concat([
      firstPath,
      Buffer.from([0]),
      secondPath,
      Buffer.from([0]),
    ]))
    expect(() => {
      renderChangeScope(['--base', 'HEAD'], root)
    }).toThrow('cannot inspect staged paths: Git path 1 is not valid UTF-8')
  })

  it('rejects missing, ambiguous, and non-commit refs', () => {
    const { root } = fixture()
    git(root, ['branch', 'collision'])
    git(root, ['tag', 'collision'])
    write(join(root, 'blob.txt'), 'blob\n')
    const blobSha = git(root, ['hash-object', '-w', 'blob.txt'])
    git(root, ['tag', 'blob-ref', blobSha])

    for (const { args, message } of [
      { args: ['--base', 'missing'], message: /base ref .* does not resolve to a commit/u },
      { args: ['--base', 'collision'], message: /base ref .* is ambiguous/u },
      { args: ['--base', 'blob-ref'], message: /base ref .* does not resolve to a commit/u },
      { args: ['--base', 'HEAD', '--head', 'missing'], message: /head ref .* does not resolve to a commit/u },
    ]) {
      expect(() => {
        renderChangeScope(args, root)
      }).toThrow(message)
    }
  })

  it('renders deterministic versioned JSON', () => {
    const { root } = fixture()
    git(root, ['switch', '-c', 'format'])
    commit(root, 'zeta.txt', 'zeta\n')
    commit(root, 'alpha.txt', 'alpha\n')

    const json = invoke(root, ['--base', 'origin/master'])
    const repeatedJson = invoke(root, ['--base', 'origin/master'])
    const report = JSON.parse(json) as Report

    expect(json).toBe(repeatedJson)
    expect(report.formatVersion).toBe(1)
    expect(report.paths.committed).toEqual(['alpha.txt', 'zeta.txt'])
  })
})
