import { execFile } from 'node:child_process'
import { createHash } from 'node:crypto'
import { mkdtemp, mkdir, readFile, readdir, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { pathToFileURL } from 'node:url'
import { promisify } from 'node:util'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { BUNDLED_PNPM_VERSION, RepositoryCache, type RepositoryInstall } from '@cordisjs/plugin-loader/repository'

const execFileAsync = promisify(execFile)
const roots: string[] = []

async function temporaryRoot(name: string): Promise<string> {
  const root = await mkdtemp(join(tmpdir(), `cordis-${name}-`))
  roots.push(root)
  return root
}

async function fakePackage(directory: string): Promise<void> {
  const target = join(directory, 'node_modules', 'repository')
  await mkdir(target, { recursive: true })
  await writeFile(join(target, 'package.json'), '{"name":"fixture"}\n')
}

afterEach(async () => {
  vi.unstubAllEnvs()
  await Promise.all(roots.splice(0).map(root => rm(root, { recursive: true, force: true })))
})

describe('RepositoryCache', () => {
  it('single-flights and permanently reuses an exact specifier', async () => {
    const root = await temporaryRoot('repository-cache')
    const calls: string[] = []
    const install: RepositoryInstall = async (directory) => {
      calls.push(directory)
      await fakePackage(directory)
    }
    const cache = new RepositoryCache(root, install)
    const specifier = 'github:owner/repository#0123456789abcdef'

    const [first, concurrent] = await Promise.all([cache.resolve(specifier), cache.resolve(specifier)])
    expect(concurrent).toBe(first)
    expect(calls).toHaveLength(1)

    const reopened = new RepositoryCache(root, async () => { throw new Error('cache miss') })
    expect(await reopened.resolve(specifier)).toBe(first)
    expect(JSON.parse(await readFile(join(first, '..', '..', 'package.json'), 'utf8'))).toMatchObject({
      packageManager: `pnpm@${BUNDLED_PNPM_VERSION}`,
      dependencies: { repository: specifier },
    })

    const second = await cache.resolve('github:owner/repository#fedcba9876543210')
    expect(second).not.toBe(first)
    expect(calls).toHaveLength(2)
  })

  it('accepts the valid winner when independent cache instances race', async () => {
    const root = await temporaryRoot('repository-race')
    const bothStarted = Promise.withResolvers<undefined>()
    let starts = 0
    const install: RepositoryInstall = async (directory) => {
      await fakePackage(directory)
      starts += 1
      if (starts === 2) bothStarted.resolve(undefined)
      await bothStarted.promise
    }
    const specifier = 'github:owner/repository#race'

    const [first, second] = await Promise.all([
      new RepositoryCache(root, install).resolve(specifier),
      new RepositoryCache(root, install).resolve(specifier),
    ])

    expect(second).toBe(first)
    expect(starts).toBe(2)
    expect(await readdir(root)).toHaveLength(1)
  })

  it('removes a failed staging tree and permits an exact retry', async () => {
    const root = await temporaryRoot('repository-retry')
    let attempts = 0
    const cache = new RepositoryCache(root, async (directory) => {
      attempts += 1
      if (attempts === 1) throw new Error('install failed')
      await fakePackage(directory)
    })

    await expect(cache.resolve('github:owner/repository#ref')).rejects.toThrow('failed to prepare repository')
    expect(await readdir(root)).toEqual([])
    await expect(cache.resolve('github:owner/repository#ref')).resolves.toContain('node_modules')
    expect(attempts).toBe(2)
  })

  it('rejects empty or padded specifiers before touching the cache', async () => {
    const root = await temporaryRoot('repository-input')
    const cache = new RepositoryCache(root, fakePackage)
    expect(() => cache.resolve('')).toThrow('non-empty unpadded string')
    expect(() => cache.resolve(' github:owner/repository#ref')).toThrow('non-empty unpadded string')
    await expect(readdir(root)).resolves.toEqual([])
  })

  it('fails loud on a corrupt published marker instead of reinstalling it', async () => {
    const root = await temporaryRoot('repository-corrupt')
    const specifier = 'github:owner/repository#corrupt'
    const key = createHash('sha256').update(specifier).digest('hex')
    const entry = join(root, key)
    await mkdir(join(entry, 'node_modules', 'repository'), { recursive: true })
    await writeFile(join(entry, '.repository-cache.json'), '{}\n')
    const cache = new RepositoryCache(root, async () => { throw new Error('must not reinstall') })

    await expect(cache.resolve(specifier)).rejects.toThrow('repository cache marker is invalid')
  })

  it('selects and prepares a root .dsh-plugin Git subpath through the bundled pnpm', { timeout: 60_000 }, async () => {
    const root = await temporaryRoot('repository-pnpm')
    const repository = join(root, 'source')
    await mkdir(join(repository, '.dsh-plugin'), { recursive: true })
    await mkdir(join(repository, 'skills', 'fixture'), { recursive: true })
    await writeFile(join(repository, 'package.json'), `${JSON.stringify({
      name: 'repository-fixture',
      version: '1.0.0',
    })}\n`)
    await writeFile(join(repository, 'skills', 'fixture', 'SKILL.md'), 'repository skill source\n')
    await writeFile(join(repository, '.dsh-plugin', 'package.json'), `${JSON.stringify({
      name: 'repository-plugin-fixture',
      version: '1.0.0',
      scripts: { prepare: 'node prepare.mjs' },
      dsh: { skills: ['../skills'] },
    })}\n`)
    await writeFile(join(repository, '.dsh-plugin', 'prepare.mjs'), [
      "import { cp, mkdir, writeFile } from 'node:fs/promises'",
      "await mkdir('dsh-plugin-assets/skills', { recursive: true })",
      "await cp('../skills', 'dsh-plugin-assets/skills/0', { recursive: true })",
      "await writeFile('dsh-plugin.mjs', 'export function apply() {}\\n')",
      "await writeFile('prepared.txt', `${process.env.REPOSITORY_TEST_VISIBLE ?? 'absent'}|${process.env.REPOSITORY_TEST_TOKEN ?? 'absent'}\\n`)",
      '',
    ].join('\n'))
    await execFileAsync('git', ['init', '--quiet'], { cwd: repository })
    await execFileAsync('git', ['add', '.'], { cwd: repository })
    await execFileAsync('git', [
      '-c', 'user.name=Repository Fixture',
      '-c', 'user.email=repository@example.invalid',
      'commit', '--quiet', '-m', 'fixture',
    ], { cwd: repository })
    const { stdout } = await execFileAsync('git', ['rev-parse', 'HEAD'], { cwd: repository, encoding: 'utf8' })
    const specifier = `git+${pathToFileURL(repository).href}#${stdout.trim()}&path:/.dsh-plugin`
    vi.stubEnv('REPOSITORY_TEST_VISIBLE', 'visible')
    vi.stubEnv('REPOSITORY_TEST_TOKEN', 'hidden')

    const installed = await new RepositoryCache(join(root, 'cache')).resolve(specifier)
    await expect(readFile(join(installed, 'prepared.txt'), 'utf8')).resolves.toBe('visible|absent\n')
    await expect(readFile(join(installed, 'dsh-plugin.mjs'), 'utf8')).resolves.toContain('export function apply')
    await expect(readFile(join(installed, 'dsh-plugin-assets/skills/0/fixture/SKILL.md'), 'utf8'))
      .resolves.toBe('repository skill source\n')
    await expect(readFile(join(installed, 'package.json'), 'utf8'))
      .resolves.toContain('repository-plugin-fixture')
  })
})
