import { mkdir, readFile, writeFile } from 'node:fs/promises'
import { join } from 'node:path'
import { afterEach, describe, expect, test } from 'bun:test'
import { zipSync, strToU8 } from 'fflate'
import { createTempHome } from '../helpers/temp-env'
import { startFakeRegistry } from '../helpers/fake-registry'
import { runCli } from '../helpers/run-cli'

// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------

/** Build a minimal valid zip containing a SKILL.md file. */
function makeSkillZip(extra: Record<string, string> = {}): Uint8Array {
  const entries: Record<string, Uint8Array> = {
    'SKILL.md': strToU8('# test skill'),
    ...Object.fromEntries(Object.entries(extra).map(([k, v]) => [k, strToU8(v)]))
  }
  return zipSync(entries)
}

let registry: Awaited<ReturnType<typeof startFakeRegistry>> | undefined

afterEach(() => {
  registry?.stop()
  registry = undefined
})

// ---------------------------------------------------------------------------
// P0 — Happy-path install: metadata.json + inventory.json
// ---------------------------------------------------------------------------

describe('install command — P0', () => {
  test('happy-path: exit 0, writes metadata.json and inventory.json', async () => {
    const env = await createTempHome()
    registry = await startFakeRegistry({
      token: 'sk_ok',
      user: { handle: 'u1', displayName: 'User One' },
      skills: [
        {
          namespace: 'global',
          slug: 'pdf-parser',
          version: '1.0.0',
          versionId: 1,
          fingerprint: 'abc123',
          zipBytes: makeSkillZip()
        }
      ]
    })

    // Login first so credentials are stored
    const loginResult = await runCli(
      ['login', '--registry', registry.url, '--token', 'sk_ok'],
      { HOME: env.home, USERPROFILE: env.home }
    )
    expect(loginResult.exitCode).toBe(0)

    // The claude-code profile installs into <cwd>/.claude/skills
    // We use --dir to pin the install directory to a known temp path so we
    // can assert on it without depending on agent detection.
    const installDir = join(env.cwd, 'skills')
    await mkdir(installDir, { recursive: true })

    const result = await runCli(
      ['install', 'pdf-parser', '--dir', installDir, '--registry', registry.url, '--token', 'sk_ok'],
      { HOME: env.home, USERPROFILE: env.home }
    )

    expect(result.exitCode).toBe(0)

    // --- metadata.json ---
    const metaPath = join(installDir, 'pdf-parser', '.skillhub', 'metadata.json')
    const meta = JSON.parse(await readFile(metaPath, 'utf-8'))
    expect(meta).toMatchObject({
      registry: registry.url,
      namespace: 'global',
      slug: 'pdf-parser',
      version: '1.0.0'
    })
    expect(typeof meta.installedAt).toBe('string')

    // --- inventory.json ---
    const inventoryPath = join(env.home, '.skillhub', 'inventory.json')
    const inventory = JSON.parse(await readFile(inventoryPath, 'utf-8'))
    expect(inventory.items).toBeArray()
    const item = inventory.items.find(
      (i: { namespace: string; slug: string }) => i.namespace === 'global' && i.slug === 'pdf-parser'
    )
    expect(item).toBeDefined()
    expect(item.targets.length).toBeGreaterThan(0)
    const target = item.targets.find(
      (t: { installDir: string }) => t.installDir === join(installDir, 'pdf-parser')
    )
    expect(target).toBeDefined()
  })

  // -------------------------------------------------------------------------
  // P0 — --json output shape
  // -------------------------------------------------------------------------

  test('--json output matches { ok, namespace, slug, installed }', async () => {
    const env = await createTempHome()
    registry = await startFakeRegistry({
      token: 'sk_ok',
      user: { handle: 'u1', displayName: 'User One' },
      skills: [
        {
          namespace: 'global',
          slug: 'pdf-parser',
          version: '1.0.0',
          zipBytes: makeSkillZip()
        }
      ]
    })

    await runCli(
      ['login', '--registry', registry.url, '--token', 'sk_ok'],
      { HOME: env.home, USERPROFILE: env.home }
    )

    const installDir = join(env.cwd, 'skills-json')
    await mkdir(installDir, { recursive: true })

    const result = await runCli(
      ['install', 'pdf-parser', '--dir', installDir, '--registry', registry.url, '--token', 'sk_ok', '--json'],
      { HOME: env.home, USERPROFILE: env.home }
    )

    expect(result.exitCode).toBe(0)
    const parsed = JSON.parse(result.stdout)
    expect(parsed).toMatchObject({
      ok: true,
      namespace: 'global',
      slug: 'pdf-parser'
    })
    expect(Array.isArray(parsed.installed)).toBe(true)
    expect(parsed.installed.length).toBeGreaterThan(0)
    expect(parsed.installed[0]).toHaveProperty('agent')
    expect(parsed.installed[0]).toHaveProperty('dir')
  })
})

// ---------------------------------------------------------------------------
// P1 — --version forwarding
// ---------------------------------------------------------------------------

describe('install command — P1', () => {
  test('--version forwards to resolve and installs the requested version', async () => {
    const env = await createTempHome()
    registry = await startFakeRegistry({
      token: 'sk_ok',
      user: { handle: 'u1', displayName: 'User One' },
      skills: [
        {
          namespace: 'global',
          slug: 'pdf-parser',
          // fake-registry returns this as the resolved version regardless of
          // the ?version= query param; we just verify the metadata records it.
          version: '1.0.0',
          zipBytes: makeSkillZip()
        }
      ]
    })

    await runCli(
      ['login', '--registry', registry.url, '--token', 'sk_ok'],
      { HOME: env.home, USERPROFILE: env.home }
    )

    const installDir = join(env.cwd, 'skills-ver')
    await mkdir(installDir, { recursive: true })

    const result = await runCli(
      [
        'install', 'pdf-parser',
        '--version', '1.0.0',
        '--dir', installDir,
        '--registry', registry.url,
        '--token', 'sk_ok'
      ],
      { HOME: env.home, USERPROFILE: env.home }
    )

    expect(result.exitCode).toBe(0)
    expect(registry.received.resolve?.version).toBe('1.0.0')

    const metaPath = join(installDir, 'pdf-parser', '.skillhub', 'metadata.json')
    const meta = JSON.parse(await readFile(metaPath, 'utf-8'))
    expect(meta.version).toBe('1.0.0')
  })

  // -------------------------------------------------------------------------
  // P1 — 401 on resolve → EXIT.auth (exit code 2)
  // -------------------------------------------------------------------------

  test('401 on resolve returns auth exit code and stderr message', async () => {
    const env = await createTempHome()
    registry = await startFakeRegistry({
      token: 'sk_ok',
      failures: { resolve: 'auth' }
    })

    const installDir = join(env.cwd, 'skills-auth')
    await mkdir(installDir, { recursive: true })

    const result = await runCli(
      [
        'install', 'pdf-parser',
        '--dir', installDir,
        '--registry', registry.url,
        '--token', 'sk_bad'
      ],
      { HOME: env.home, USERPROFILE: env.home }
    )

    // EXIT.auth = 2
    expect(result.exitCode).toBe(2)
    expect(result.stderr.toLowerCase()).toMatch(/auth|unauthorized|401/)
  })

  test('bad token stops on 401 without retrying resolve anonymously', async () => {
    const env = await createTempHome()
    const installDir = join(env.cwd, 'skills-no-anon-retry')
    await mkdir(installDir, { recursive: true })

    const resolveAuthHeaders: Array<string | null> = []
    let downloadRequests = 0
    const server = Bun.serve({
      port: 0,
      fetch(req) {
        const url = new URL(req.url)
        const resolveMatch = url.pathname.match(/^\/api\/cli\/v1\/skills\/([^/]+)\/([^/]+)\/resolve$/)
        if (resolveMatch) {
          const auth = req.headers.get('authorization')
          resolveAuthHeaders.push(auth)
          if (auth === 'Bearer sk_bad') {
            return Response.json({ code: 401, message: 'unauthorized' }, { status: 401 })
          }
          return Response.json({
            code: 0,
            data: {
              namespace: resolveMatch[1],
              slug: resolveMatch[2],
              version: '1.0.0',
              versionId: 1,
              fingerprint: 'abc123',
              downloadUrl: `${url.protocol}//${url.host}/api/cli/v1/skills/${resolveMatch[1]}/${resolveMatch[2]}/download`
            }
          })
        }
        if (url.pathname.endsWith('/download')) {
          downloadRequests += 1
          return new Response(makeSkillZip() as BodyInit, {
            status: 200,
            headers: { 'Content-Type': 'application/zip' }
          })
        }
        return Response.json({ code: 404 }, { status: 404 })
      }
    })

    try {
      const registryUrl = `http://localhost:${server.port}`
      const result = await runCli(
        ['install', 'pdf-parser', '--dir', installDir, '--registry', registryUrl, '--token', 'sk_bad'],
        { HOME: env.home, USERPROFILE: env.home }
      )

      expect(result.exitCode).toBe(2)
      expect(result.stderr).toContain('Error: authentication failed')
      expect(result.stderr).toContain(`Context: registry ${registryUrl}`)
      expect(result.stderr).toContain('Next:')
      expect(resolveAuthHeaders).toEqual(['Bearer sk_bad'])
      expect(downloadRequests).toBe(0)
    } finally {
      server.stop()
    }
  })

  // -------------------------------------------------------------------------
  // P1 — --namespace override
  // -------------------------------------------------------------------------

  test('--namespace override installs under the specified namespace', async () => {
    const env = await createTempHome()
    registry = await startFakeRegistry({
      token: 'sk_ok',
      user: { handle: 'u1', displayName: 'User One' },
      skills: [
        {
          namespace: 'myteam',
          slug: 'mything',
          version: '2.0.0',
          zipBytes: makeSkillZip()
        }
      ]
    })

    await runCli(
      ['login', '--registry', registry.url, '--token', 'sk_ok'],
      { HOME: env.home, USERPROFILE: env.home }
    )

    const installDir = join(env.cwd, 'skills-ns')
    await mkdir(installDir, { recursive: true })

    const result = await runCli(
      [
        'install', 'mything',
        '--namespace', 'myteam',
        '--dir', installDir,
        '--registry', registry.url,
        '--token', 'sk_ok'
      ],
      { HOME: env.home, USERPROFILE: env.home }
    )

    expect(result.exitCode).toBe(0)

    const metaPath = join(installDir, 'mything', '.skillhub', 'metadata.json')
    const meta = JSON.parse(await readFile(metaPath, 'utf-8'))
    expect(meta.namespace).toBe('myteam')
    expect(meta.slug).toBe('mything')
    expect(meta.version).toBe('2.0.0')
  })

  test.each([
    'team/my-skill',
    '@team/my-skill',
    'team--my-skill'
  ])('%s resolves the namespaced registry path', async (coordinate) => {
    const env = await createTempHome()
    registry = await startFakeRegistry({
      token: 'sk_ok',
      skills: [{
        namespace: 'team',
        slug: 'my-skill',
        version: '1.0.0',
        zipBytes: makeSkillZip()
      }]
    })

    const installDir = join(env.cwd, 'skills-coordinate')
    await mkdir(installDir, { recursive: true })

    const result = await runCli(
      [
        'install', coordinate,
        '--dir', installDir,
        '--registry', registry.url,
        '--token', 'sk_ok',
        '--json'
      ],
      { HOME: env.home, USERPROFILE: env.home }
    )

    expect(result.exitCode).toBe(0)
    expect(JSON.parse(result.stdout)).toMatchObject({
      ok: true,
      namespace: 'team',
      slug: 'my-skill'
    })
    expect(registry.received.resolve).toMatchObject({
      namespace: 'team',
      slug: 'my-skill'
    })
  })

  test('coordinate conflicting with --namespace fails before registry access', async () => {
    const env = await createTempHome()
    registry = await startFakeRegistry({
      token: 'sk_ok',
      skills: [{
        namespace: 'team',
        slug: 'my-skill',
        version: '1.0.0',
        zipBytes: makeSkillZip()
      }]
    })

    const installDir = join(env.cwd, 'skills-coordinate-conflict')
    await mkdir(installDir, { recursive: true })

    const result = await runCli(
      [
        'install', '@team/my-skill',
        '--namespace', 'other',
        '--dir', installDir,
        '--registry', registry.url,
        '--token', 'sk_ok',
        '--json'
      ],
      { HOME: env.home, USERPROFILE: env.home }
    )

    expect(result.exitCode).toBe(5)
    expect(JSON.parse(result.stderr)).toMatchObject({
      ok: false,
      exitCode: 5
    })
    expect(registry.received.resolve).toBeNull()
  })

  // -------------------------------------------------------------------------
  // NOTE: multi-target interactive selection (TTY branch) is not tested here
  // because Bun.spawn does not support PTY allocation. The interactive path
  // in resolveInstallTargets() is covered by the unit tests in
  // test/unit/agents/resolver.test.ts.
  // -------------------------------------------------------------------------
})

// ---------------------------------------------------------------------------
// P0/P1 — Conflict & --force handling
// ---------------------------------------------------------------------------

describe('install command — conflict and --force', () => {
  test('re-installing without --force into an existing dir errors with EXIT.filesystem', async () => {
    const env = await createTempHome()
    registry = await startFakeRegistry({
      token: 'sk_ok',
      user: { handle: 'u1', displayName: 'User One' },
      skills: [{ namespace: 'global', slug: 'pdf-parser', version: '1.0.0', zipBytes: makeSkillZip() }]
    })
    await runCli(['login', '--registry', registry.url, '--token', 'sk_ok'], { HOME: env.home, USERPROFILE: env.home })

    const installDir = join(env.cwd, 'skills-conflict')
    await mkdir(installDir, { recursive: true })

    const first = await runCli(
      ['install', 'pdf-parser', '--dir', installDir, '--registry', registry.url, '--token', 'sk_ok'],
      { HOME: env.home, USERPROFILE: env.home }
    )
    expect(first.exitCode).toBe(0)

    const second = await runCli(
      ['install', 'pdf-parser', '--dir', installDir, '--registry', registry.url, '--token', 'sk_ok'],
      { HOME: env.home, USERPROFILE: env.home }
    )
    expect(second.exitCode).toBe(4) // EXIT.filesystem
    expect(second.stderr).toContain('already installed')
    expect(second.stderr).toContain('--force')
  })

  test('--force overwrites stale files left in the install dir', async () => {
    const env = await createTempHome()
    registry = await startFakeRegistry({
      token: 'sk_ok',
      user: { handle: 'u1', displayName: 'User One' },
      skills: [{ namespace: 'global', slug: 'pdf-parser', version: '1.0.0', zipBytes: makeSkillZip() }]
    })
    await runCli(['login', '--registry', registry.url, '--token', 'sk_ok'], { HOME: env.home, USERPROFILE: env.home })

    const installDir = join(env.cwd, 'skills-force')
    await mkdir(installDir, { recursive: true })
    await runCli(
      ['install', 'pdf-parser', '--dir', installDir, '--registry', registry.url, '--token', 'sk_ok'],
      { HOME: env.home, USERPROFILE: env.home }
    )

    // Tamper with SKILL.md to prove the second install replaces it.
    const skillFile = join(installDir, 'pdf-parser', 'SKILL.md')
    await writeFile(skillFile, '# tampered content')
    expect(await readFile(skillFile, 'utf-8')).toBe('# tampered content')

    const forced = await runCli(
      ['install', 'pdf-parser', '--dir', installDir, '--registry', registry.url, '--token', 'sk_ok', '--force'],
      { HOME: env.home, USERPROFILE: env.home }
    )
    expect(forced.exitCode).toBe(0)
    expect(await readFile(skillFile, 'utf-8')).toBe('# test skill')
  })
})

// ---------------------------------------------------------------------------
// P1 — Server-side error mapping during install
// ---------------------------------------------------------------------------

describe('install command — server errors', () => {
  test('resolve 404 surfaces an error and aborts install (no metadata.json written)', async () => {
    const env = await createTempHome()
    registry = await startFakeRegistry({
      token: 'sk_ok',
      failures: { resolve: 'not_found' }
    })
    await runCli(['login', '--registry', registry.url, '--token', 'sk_ok'], { HOME: env.home, USERPROFILE: env.home })

    const installDir = join(env.cwd, 'skills-resolve-404')
    await mkdir(installDir, { recursive: true })

    const result = await runCli(
      ['install', 'no-such-slug', '--dir', installDir, '--registry', registry.url, '--token', 'sk_ok'],
      { HOME: env.home, USERPROFILE: env.home }
    )

    expect(result.exitCode).not.toBe(0)
    expect(result.stderr).toMatch(/404|not found/i)

    // No metadata file should have been created at the install destination.
    const metaPath = join(installDir, 'no-such-slug', '.skillhub', 'metadata.json')
    expect(await Bun.file(metaPath).exists()).toBe(false)
  })

  // Regression test for the production bug observed on 2026-05-06: server
  // marks `bundle_ready=true` in DB but the bundle file is missing on disk.
  // /resolve returns 200 with a downloadUrl, then /download returns 404. The
  // CLI must surface a non-zero exit and a meaningful stderr — not silently
  // succeed with an empty install dir.
  test('download 404 (resolve OK) is reported as a download failure', async () => {
    const env = await createTempHome()
    registry = await startFakeRegistry({
      token: 'sk_ok',
      user: { handle: 'u1', displayName: 'User One' },
      // resolve succeeds (skill is present in fixture list) but the download
      // endpoint is forced to 404 to simulate a missing bundle on storage.
      skills: [{ namespace: 'global', slug: 'orphan-bundle', version: '1.0.0', zipBytes: makeSkillZip() }],
      failures: { download: 'not_found' }
    })
    await runCli(['login', '--registry', registry.url, '--token', 'sk_ok'], { HOME: env.home, USERPROFILE: env.home })

    const installDir = join(env.cwd, 'skills-bundle-missing')
    await mkdir(installDir, { recursive: true })

    const result = await runCli(
      ['install', 'orphan-bundle', '--dir', installDir, '--registry', registry.url, '--token', 'sk_ok'],
      { HOME: env.home, USERPROFILE: env.home }
    )

    expect(result.exitCode).not.toBe(0)
    expect(result.stderr.toLowerCase()).toMatch(/download|404|not found/)
  })

  // -------------------------------------------------------------------------
  // P1 — Path safety: install only writes inside <dir>/<slug>/
  // -------------------------------------------------------------------------

  test('install only writes inside <dir>/<slug>/ — sibling files in <dir> are untouched', async () => {
    const env = await createTempHome()
    registry = await startFakeRegistry({
      token: 'sk_ok',
      user: { handle: 'u', displayName: 'U' },
      skills: [{ namespace: 'global', slug: 'pdf-parser', version: '1.0.0', zipBytes: makeSkillZip() }]
    })
    await runCli(['login', '--registry', registry.url, '--token', 'sk_ok'], { HOME: env.home, USERPROFILE: env.home })

    const installDir = join(env.cwd, 'shared-dir')
    await mkdir(installDir, { recursive: true })
    // Place an unrelated file as a sibling of the future <slug>/ subdir.
    const sibling = join(installDir, 'IMPORTANT.txt')
    await writeFile(sibling, 'this file must survive install')

    const result = await runCli(
      ['install', 'pdf-parser', '--dir', installDir, '--registry', registry.url, '--token', 'sk_ok'],
      { HOME: env.home, USERPROFILE: env.home }
    )
    expect(result.exitCode).toBe(0)

    // Sibling file must still exist with original content.
    expect(await readFile(sibling, 'utf-8')).toBe('this file must survive install')
    // <slug>/ subdir created.
    expect(await Bun.file(join(installDir, 'pdf-parser', 'SKILL.md')).exists()).toBe(true)
  })

  test('--force re-install does not touch sibling files in <dir>', async () => {
    const env = await createTempHome()
    registry = await startFakeRegistry({
      token: 'sk_ok',
      user: { handle: 'u', displayName: 'U' },
      skills: [{ namespace: 'global', slug: 'pdf-parser', version: '1.0.0', zipBytes: makeSkillZip() }]
    })
    await runCli(['login', '--registry', registry.url, '--token', 'sk_ok'], { HOME: env.home, USERPROFILE: env.home })

    const installDir = join(env.cwd, 'shared-force')
    await mkdir(installDir, { recursive: true })
    await runCli(
      ['install', 'pdf-parser', '--dir', installDir, '--registry', registry.url, '--token', 'sk_ok'],
      { HOME: env.home, USERPROFILE: env.home }
    )

    // After first install, drop a sibling file; --force should not delete it.
    const sibling = join(installDir, 'sibling-after-install.bin')
    await writeFile(sibling, 'sentinel')

    const r2 = await runCli(
      ['install', 'pdf-parser', '--dir', installDir, '--registry', registry.url, '--token', 'sk_ok', '--force'],
      { HOME: env.home, USERPROFILE: env.home }
    )
    expect(r2.exitCode).toBe(0)
    expect(await readFile(sibling, 'utf-8')).toBe('sentinel')
  })

  test('install --dir creates the <slug> subdir even when <dir> is empty', async () => {
    const env = await createTempHome()
    registry = await startFakeRegistry({
      token: 'sk_ok',
      user: { handle: 'u', displayName: 'U' },
      skills: [{ namespace: 'global', slug: 'pdf-parser', version: '1.0.0', zipBytes: makeSkillZip() }]
    })
    await runCli(['login', '--registry', registry.url, '--token', 'sk_ok'], { HOME: env.home, USERPROFILE: env.home })

    const installDir = join(env.cwd, 'empty-dir')
    await mkdir(installDir, { recursive: true })

    const result = await runCli(
      ['install', 'pdf-parser', '--dir', installDir, '--registry', registry.url, '--token', 'sk_ok'],
      { HOME: env.home, USERPROFILE: env.home }
    )
    expect(result.exitCode).toBe(0)
    expect(await Bun.file(join(installDir, 'pdf-parser', 'SKILL.md')).exists()).toBe(true)
    expect(await Bun.file(join(installDir, 'pdf-parser', '.skillhub', 'metadata.json')).exists()).toBe(true)
  })

  test('install --dir pointing at a regular file (not a directory) fails before download', async () => {
    const env = await createTempHome()
    registry = await startFakeRegistry({
      token: 'sk_ok',
      user: { handle: 'u', displayName: 'U' },
      skills: [{ namespace: 'global', slug: 'pdf-parser', version: '1.0.0', zipBytes: makeSkillZip() }]
    })
    await runCli(['login', '--registry', registry.url, '--token', 'sk_ok'], { HOME: env.home, USERPROFILE: env.home })

    // Create a file at the location --dir would otherwise treat as a directory.
    const filePath = join(env.cwd, 'not-a-dir')
    await writeFile(filePath, 'i am a file, not a dir')

    const result = await runCli(
      ['install', 'pdf-parser', '--dir', filePath, '--registry', registry.url, '--token', 'sk_ok'],
      { HOME: env.home, USERPROFILE: env.home }
    )
    expect(result.exitCode).not.toBe(0)
    // Original file must still be unchanged (the install should not have
    // scribbled on it before bailing).
    expect(await readFile(filePath, 'utf-8')).toBe('i am a file, not a dir')
  })

  test('--json emits a parseable error envelope when install fails', async () => {
    const env = await createTempHome()
    registry = await startFakeRegistry({
      token: 'sk_ok',
      failures: { resolve: 'not_found' }
    })
    await runCli(['login', '--registry', registry.url, '--token', 'sk_ok'], { HOME: env.home, USERPROFILE: env.home })

    const installDir = join(env.cwd, 'skills-json-error')
    await mkdir(installDir, { recursive: true })

    const result = await runCli(
      ['install', 'no-such-slug', '--dir', installDir, '--registry', registry.url, '--token', 'sk_ok', '--json'],
      { HOME: env.home, USERPROFILE: env.home }
    )

    expect(result.exitCode).not.toBe(0)
    // JSON error envelope is printed to stdout (or stderr, depending on the
    // command); we accept either to keep the test resilient to that choice.
    const candidate = result.stdout || result.stderr
    const json = JSON.parse(candidate) as {
      ok: boolean
      message: string
      exitCode: number
    }
    expect(json.ok).toBe(false)
    expect(typeof json.message).toBe('string')
    expect(json.exitCode).toBe(result.exitCode)
  })
})

// ---------------------------------------------------------------------------
// P1 — Multi-agent and auto-detect targeting
// ---------------------------------------------------------------------------

describe('install command — multi-agent & auto-detect', () => {
  test('multi --agent installs the same skill into every specified user-level dir', async () => {
    const env = await createTempHome()
    registry = await startFakeRegistry({
      token: 'sk_ok',
      user: { handle: 'u', displayName: 'U' },
      skills: [{ namespace: 'global', slug: 'pdf-parser', version: '1.0.0', zipBytes: makeSkillZip() }]
    })
    await runCli(['login', '--registry', registry.url, '--token', 'sk_ok'], { HOME: env.home, USERPROFILE: env.home })

    const result = await runCli(
      [
        'install', 'pdf-parser',
        '--agent', 'codex',
        '--agent', 'claude-code',
        '--registry', registry.url,
        '--token', 'sk_ok',
        '--json'
      ],
      { HOME: env.home, USERPROFILE: env.home }
    )
    expect(result.exitCode).toBe(0)
    const parsed = JSON.parse(result.stdout) as { installed: Array<{ agent: string }> }
    const agents = parsed.installed.map(t => t.agent).sort()
    expect(agents).toEqual(['claude-code', 'codex'])

    // Both metadata files exist on disk under user-level <home>/.<agent>/skills.
    expect(await Bun.file(join(env.home, '.codex', 'skills', 'pdf-parser', '.skillhub', 'metadata.json')).exists()).toBe(true)
    expect(await Bun.file(join(env.home, '.claude', 'skills', 'pdf-parser', '.skillhub', 'metadata.json')).exists()).toBe(true)
  })

  test('duplicate --agent dedupes to one target', async () => {
    const env = await createTempHome()
    registry = await startFakeRegistry({
      token: 'sk_ok',
      user: { handle: 'u', displayName: 'U' },
      skills: [{ namespace: 'global', slug: 'pdf-parser', version: '1.0.0', zipBytes: makeSkillZip() }]
    })
    await runCli(['login', '--registry', registry.url, '--token', 'sk_ok'], { HOME: env.home, USERPROFILE: env.home })

    const result = await runCli(
      [
        'install', 'pdf-parser',
        '--agent', 'codex',
        '--agent', 'codex',
        '--registry', registry.url,
        '--token', 'sk_ok',
        '--json'
      ],
      { HOME: env.home, USERPROFILE: env.home }
    )
    expect(result.exitCode).toBe(0)
    const parsed = JSON.parse(result.stdout) as { installed: Array<{ agent: string }> }
    expect(parsed.installed).toHaveLength(1)
    expect(parsed.installed[0]?.agent).toBe('codex')
  })

  test('--agent unknown-id surfaces a usage error with hint', async () => {
    const env = await createTempHome()
    registry = await startFakeRegistry({
      token: 'sk_ok',
      user: { handle: 'u', displayName: 'U' },
      skills: [{ namespace: 'global', slug: 'pdf-parser', version: '1.0.0', zipBytes: makeSkillZip() }]
    })
    await runCli(['login', '--registry', registry.url, '--token', 'sk_ok'], { HOME: env.home, USERPROFILE: env.home })

    const result = await runCli(
      ['install', 'pdf-parser', '--agent', 'totally-not-a-real-agent', '--registry', registry.url, '--token', 'sk_ok'],
      { HOME: env.home, USERPROFILE: env.home }
    )
    expect(result.exitCode).toBe(5) // EXIT.usage
    expect(result.stderr.toLowerCase()).toMatch(/unknown agent|--dir/)
  })

  test('auto-detect: cwd with only .codex/skills present installs project-level there', async () => {
    const env = await createTempHome()
    registry = await startFakeRegistry({
      token: 'sk_ok',
      user: { handle: 'u', displayName: 'U' },
      skills: [{ namespace: 'global', slug: 'pdf-parser', version: '1.0.0', zipBytes: makeSkillZip() }]
    })
    await runCli(['login', '--registry', registry.url, '--token', 'sk_ok'], { HOME: env.home, USERPROFILE: env.home })

    // Pre-create the codex skills dir so auto-detect picks project scope.
    await mkdir(join(env.cwd, '.codex', 'skills'), { recursive: true })

    const result = await runCli(
      ['install', 'pdf-parser', '--registry', registry.url, '--token', 'sk_ok', '--json'],
      { HOME: env.home, USERPROFILE: env.home },
      { cwd: env.cwd }
    )
    expect(result.exitCode).toBe(0)

    const parsed = JSON.parse(result.stdout) as { installed: Array<{ dir: string; agent: string }> }
    expect(parsed.installed[0]?.agent).toBe('codex')
    // On macOS env.cwd may resolve through /private/var/... symlinks; assert
    // against the structural part of the path instead of an exact prefix.
    // Use a regex that accepts both Unix (/) and Windows (\) path separators.
    expect(parsed.installed[0]?.dir).toMatch(/[/\\]\.codex[/\\]skills[/\\]pdf-parser/)
    expect(parsed.installed[0]?.dir).not.toContain(env.home) // not user-level
  })

  test('auto-detect: multiple agent dirs in cwd and non-interactive mode fails with hint', async () => {
    const env = await createTempHome()
    registry = await startFakeRegistry({
      token: 'sk_ok',
      user: { handle: 'u', displayName: 'U' },
      skills: [{ namespace: 'global', slug: 'pdf-parser', version: '1.0.0', zipBytes: makeSkillZip() }]
    })
    await runCli(['login', '--registry', registry.url, '--token', 'sk_ok'], { HOME: env.home, USERPROFILE: env.home })

    await mkdir(join(env.cwd, '.codex', 'skills'), { recursive: true })
    await mkdir(join(env.cwd, '.claude', 'skills'), { recursive: true })

    const result = await runCli(
      ['install', 'pdf-parser', '--registry', registry.url, '--token', 'sk_ok', '--json'],
      { HOME: env.home, USERPROFILE: env.home },
      { cwd: env.cwd }
    )
    expect(result.exitCode).toBe(5) // EXIT.usage
    expect(result.stderr.toLowerCase()).toMatch(/multiple install targets|--agent|--dir/)
  })

  test('auto-detect: cwd with no agent dirs falls back to .agents/skills', async () => {
    const env = await createTempHome()
    registry = await startFakeRegistry({
      token: 'sk_ok',
      user: { handle: 'u', displayName: 'U' },
      skills: [{ namespace: 'global', slug: 'pdf-parser', version: '1.0.0', zipBytes: makeSkillZip() }]
    })
    await runCli(['login', '--registry', registry.url, '--token', 'sk_ok'], { HOME: env.home, USERPROFILE: env.home })

    const result = await runCli(
      ['install', 'pdf-parser', '--registry', registry.url, '--token', 'sk_ok', '--json'],
      { HOME: env.home, USERPROFILE: env.home },
      { cwd: env.cwd }
    )
    expect(result.exitCode).toBe(0)
    const parsed = JSON.parse(result.stdout) as { installed: Array<{ dir: string; agent: string }> }
    expect(parsed.installed[0]?.agent).toBe('generic')
    expect(parsed.installed[0]?.dir).toContain('.agents')
  })

  // -------------------------------------------------------------------------
  // P1 — Bundle integrity: download body that's not a valid zip
  // -------------------------------------------------------------------------
  test('download body that is not a valid zip surfaces an extraction error', async () => {
    const env = await createTempHome()
    // Stand up a custom server that returns valid resolve JSON but plain
    // text on download.
    const server = Bun.serve({
      port: 0,
      fetch(req) {
        const url = new URL(req.url)
        if (url.pathname === '/api/cli/v1/auth/whoami') {
          return Response.json({ code: 0, data: { handle: 'u', displayName: 'U' } })
        }
        const baseUrl = `${url.protocol}//${url.host}`
        const resolveMatch = url.pathname.match(/^\/api\/cli\/v1\/skills\/([^/]+)\/([^/]+)\/resolve$/)
        if (resolveMatch && req.method === 'GET') {
          return Response.json({
            code: 0,
            data: {
              namespace: resolveMatch[1],
              slug: resolveMatch[2],
              version: '1.0.0',
              versionId: 1,
              fingerprint: 'deadbeef',
              downloadUrl: `${baseUrl}/api/cli/v1/skills/${resolveMatch[1]}/${resolveMatch[2]}/versions/1.0.0/download`
            }
          })
        }
        if (url.pathname.includes('/download')) {
          // NOT a zip — plain text.
          return new Response('this is plain text, not a zip', {
            status: 200, headers: { 'Content-Type': 'application/zip' }
          })
        }
        return Response.json({ code: 404, message: 'not found' }, { status: 404 })
      }
    })
    try {
      const url = `http://localhost:${server.port}`
      await runCli(['login', '--registry', url, '--token', 'sk_ok'], { HOME: env.home, USERPROFILE: env.home })

      const installDir = join(env.cwd, 'bad-bundle')
      await mkdir(installDir, { recursive: true })

      const result = await runCli(
        ['install', 'pdf-parser', '--dir', installDir, '--registry', url, '--token', 'sk_ok'],
        { HOME: env.home, USERPROFILE: env.home }
      )
      expect(result.exitCode).not.toBe(0)
      // No metadata should have been written.
      expect(await Bun.file(join(installDir, 'pdf-parser', '.skillhub', 'metadata.json')).exists()).toBe(false)
    } finally {
      server.stop()
    }
  })

  // -------------------------------------------------------------------------
  // P2 — Slug edge cases (Unicode, very long)
  // -------------------------------------------------------------------------
  test('slug with non-ASCII characters round-trips through resolve URL (encoded)', async () => {
    const env = await createTempHome()
    let resolveUrl = ''
    const server = Bun.serve({
      port: 0,
      fetch(req) {
        const url = new URL(req.url)
        if (url.pathname === '/api/cli/v1/auth/whoami') {
          return Response.json({ code: 0, data: { handle: 'u', displayName: 'U' } })
        }
        if (url.pathname.includes('/resolve')) {
          resolveUrl = req.url
          // Return 404 — we only care that the URL was constructed correctly.
          return Response.json({ code: 404, message: 'not found' }, { status: 404 })
        }
        return Response.json({ code: 404, message: 'not found' }, { status: 404 })
      }
    })
    try {
      const url = `http://localhost:${server.port}`
      await runCli(['login', '--registry', url, '--token', 'sk_ok'], { HOME: env.home, USERPROFILE: env.home })

      const installDir = join(env.cwd, 'unicode-slug')
      await mkdir(installDir, { recursive: true })

      const result = await runCli(
        ['install', '中文-技能', '--dir', installDir, '--registry', url, '--token', 'sk_ok'],
        { HOME: env.home, USERPROFILE: env.home }
      )
      // Server returns 404 — install fails. Just confirm CLI didn't crash
      // before hitting the server.
      expect(result.exitCode).not.toBe(0)
      // The slug must appear URL-percent-encoded in the resolve URL.
      expect(resolveUrl).toMatch(/%E4%B8%AD%E6%96%87/)
    } finally {
      server.stop()
    }
  })

  test('slug 200+ characters is forwarded as-is to /resolve (server is authoritative)', async () => {
    const env = await createTempHome()
    let resolveUrl = ''
    const server = Bun.serve({
      port: 0,
      fetch(req) {
        const url = new URL(req.url)
        if (url.pathname === '/api/cli/v1/auth/whoami') {
          return Response.json({ code: 0, data: { handle: 'u', displayName: 'U' } })
        }
        if (url.pathname.includes('/resolve')) {
          resolveUrl = req.url
          return Response.json({ code: 404, message: 'not found' }, { status: 404 })
        }
        return Response.json({ code: 404, message: 'not found' }, { status: 404 })
      }
    })
    try {
      const url = `http://localhost:${server.port}`
      await runCli(['login', '--registry', url, '--token', 'sk_ok'], { HOME: env.home, USERPROFILE: env.home })

      const installDir = join(env.cwd, 'long-slug')
      await mkdir(installDir, { recursive: true })

      const longSlug = 'a'.repeat(220)
      const result = await runCli(
        ['install', longSlug, '--dir', installDir, '--registry', url, '--token', 'sk_ok'],
        { HOME: env.home, USERPROFILE: env.home }
      )
      expect(result.exitCode).not.toBe(0)
      expect(resolveUrl).toContain(longSlug)
    } finally {
      server.stop()
    }
  })
})

// ---------------------------------------------------------------------------
// P0 — --scope flag
// ---------------------------------------------------------------------------

describe('install command — --scope', () => {
  test('--scope project --agent codex installs to <cwd>/.codex/skills', async () => {
    const env = await createTempHome()
    registry = await startFakeRegistry({
      token: 'sk_ok',
      user: { handle: 'u1', displayName: 'User One' },
      skills: [{ namespace: 'global', slug: 'foo', version: '1.0.0', zipBytes: makeSkillZip() }]
    })

    await runCli(
      ['login', '--registry', registry.url, '--token', 'sk_ok'],
      { HOME: env.home, USERPROFILE: env.home }
    )

    const result = await runCli(
      ['install', 'foo', '--scope', 'project', '--agent', 'codex',
        '--registry', registry.url, '--token', 'sk_ok'],
      { HOME: env.home, USERPROFILE: env.home },
      { cwd: env.cwd }
    )

    expect(result.exitCode).toBe(0)
    const metaPath = join(env.cwd, '.codex', 'skills', 'foo', '.skillhub', 'metadata.json')
    const meta = JSON.parse(await readFile(metaPath, 'utf-8'))
    expect(meta.slug).toBe('foo')
  })

  test('--scope user --agent codex installs to <home>/.codex/skills', async () => {
    const env = await createTempHome()
    registry = await startFakeRegistry({
      token: 'sk_ok',
      user: { handle: 'u1', displayName: 'User One' },
      skills: [{ namespace: 'global', slug: 'foo', version: '1.0.0', zipBytes: makeSkillZip() }]
    })

    await runCli(
      ['login', '--registry', registry.url, '--token', 'sk_ok'],
      { HOME: env.home, USERPROFILE: env.home }
    )

    const result = await runCli(
      ['install', 'foo', '--scope', 'user', '--agent', 'codex',
        '--registry', registry.url, '--token', 'sk_ok'],
      { HOME: env.home, USERPROFILE: env.home },
      { cwd: env.cwd }
    )

    expect(result.exitCode).toBe(0)
    const metaPath = join(env.home, '.codex', 'skills', 'foo', '.skillhub', 'metadata.json')
    const meta = JSON.parse(await readFile(metaPath, 'utf-8'))
    expect(meta.slug).toBe('foo')
  })

  test('--scope user clean env falls back to <home>/.agents/skills', async () => {
    const env = await createTempHome()
    registry = await startFakeRegistry({
      token: 'sk_ok',
      user: { handle: 'u1', displayName: 'User One' },
      skills: [{ namespace: 'global', slug: 'foo', version: '1.0.0', zipBytes: makeSkillZip() }]
    })

    await runCli(
      ['login', '--registry', registry.url, '--token', 'sk_ok'],
      { HOME: env.home, USERPROFILE: env.home }
    )

    const result = await runCli(
      ['install', 'foo', '--scope', 'user',
        '--registry', registry.url, '--token', 'sk_ok'],
      { HOME: env.home, USERPROFILE: env.home },
      { cwd: env.cwd }
    )

    expect(result.exitCode).toBe(0)
    const metaPath = join(env.home, '.agents', 'skills', 'foo', '.skillhub', 'metadata.json')
    const meta = JSON.parse(await readFile(metaPath, 'utf-8'))
    expect(meta.slug).toBe('foo')
  })

  test('--scope project --agent codex --json output omits scope field on installed entries', async () => {
    const env = await createTempHome()
    registry = await startFakeRegistry({
      token: 'sk_ok',
      user: { handle: 'u1', displayName: 'User One' },
      skills: [{ namespace: 'global', slug: 'foo', version: '1.0.0', zipBytes: makeSkillZip() }]
    })

    await runCli(
      ['login', '--registry', registry.url, '--token', 'sk_ok'],
      { HOME: env.home, USERPROFILE: env.home }
    )

    const result = await runCli(
      ['install', 'foo', '--scope', 'project', '--agent', 'codex', '--json',
        '--registry', registry.url, '--token', 'sk_ok'],
      { HOME: env.home, USERPROFILE: env.home },
      { cwd: env.cwd }
    )

    expect(result.exitCode).toBe(0)
    const parsed = JSON.parse(result.stdout)
    expect(parsed).toMatchObject({ ok: true, namespace: 'global', slug: 'foo' })
    expect(parsed.installed[0]).toHaveProperty('agent')
    expect(parsed.installed[0]).toHaveProperty('dir')
    expect(parsed.installed[0]).not.toHaveProperty('scope')
  })

  test('--scope invalid returns exit code 5 with usage error', async () => {
    const result = await runCli(['install', 'foo', '--scope', 'invalid'])
    expect(result.exitCode).toBe(5)
    expect(result.stderr).toMatch(/user.+project|"user".+"project"/)
  })

  test('--scope invalid --json returns JSON error shape', async () => {
    const result = await runCli(['install', 'foo', '--scope', 'invalid', '--json'])
    expect(result.exitCode).toBe(5)
    const parsed = JSON.parse(result.stderr)
    expect(parsed.ok).toBe(false)
    expect(parsed.exitCode).toBe(5)
    expect(parsed.message).toMatch(/user.+project/)
  })

  test('--dir + --scope returns usage error', async () => {
    const result = await runCli(['install', 'foo', '--dir', '/tmp/x', '--scope', 'user'])
    expect(result.exitCode).toBe(5)
    expect(result.stderr).toMatch(/--dir cannot be used with --scope/)
  })

  test('--dir + --scope --json returns JSON usage error', async () => {
    const result = await runCli(
      ['install', 'foo', '--dir', '/tmp/x', '--scope', 'user', '--json']
    )
    expect(result.exitCode).toBe(5)
    const parsed = JSON.parse(result.stderr)
    expect(parsed.ok).toBe(false)
    expect(parsed.message).toMatch(/--dir cannot be used with --scope/)
  })

  test('help install includes --scope usage and examples', async () => {
    const result = await runCli(['help', 'install'])
    expect(result.exitCode).toBe(0)
    expect(result.stdout).toMatch(/--scope/)
    expect(result.stdout).toMatch(/--scope user/)
    expect(result.stdout).toMatch(/--scope project --agent codex/)
  })
})
