import { describe, it, expect } from 'vitest'
import { readFileSync } from 'node:fs'
import { execFileSync } from 'node:child_process'
import { fileURLToPath } from 'node:url'
import path from 'node:path'

const EXT_DIR = path.join(path.dirname(fileURLToPath(import.meta.url)), '..')
const read = (name) => JSON.parse(readFileSync(path.join(EXT_DIR, name), 'utf8'))
const readText = (...seg) => readFileSync(path.join(EXT_DIR, ...seg), 'utf8')

// Only the git-free subcommands are exercised here: `version`/`patch` shell out
// to git, and the extension lane runs on node:24-bookworm-slim which may not
// ship it. Those two are covered where git is guaranteed — ci.yml and build.yml
// run on ci-python.
const packaging = (cmd) =>
  execFileSync('sh', [path.join(EXT_DIR, 'scripts', 'packaging.sh'), cmd], {
    cwd: EXT_DIR,
    encoding: 'utf8'
  })
    .trim()
    .split(/\s+/)
    .filter(Boolean)

describe('packaging.sh — the single definition of what ships', () => {
  it('emits an ignore list and a pathspec that agree on the tracked files', () => {
    const ignore = packaging('ignore')
    const pathspec = packaging('pathspec').map((e) => e.replace(':(exclude)extension/', ''))

    // Every git-excluded path must also be hidden from web-ext. The reverse is
    // not required: node_modules and web-ext-artifacts are build output git
    // never tracks, so they appear only in the ignore list.
    for (const entry of pathspec) {
      expect(ignore, `pathspec has "${entry}" but --ignore-files does not`).toContain(entry)
    }
    expect(pathspec.length).toBeGreaterThan(0)
    expect(ignore).toContain('node_modules')
  })

  it('emits glob patterns literally, never expanded against the working tree', () => {
    // The script iterates its lists with deliberate word-splitting, so it must
    // run with pathname expansion off. Without that, invoking it from a cwd
    // where test/ exists (exactly how ci.yml and vitest call it) expands
    // `test/**` into the individual spec files, and the pathspec silently stops
    // covering anything added later.
    const pathspec = packaging('pathspec')
    expect(pathspec).toContain(':(exclude)extension/test/**')
    expect(pathspec.some((e) => e.includes('.spec.js'))).toBe(false)
    expect(pathspec.some((e) => e.includes('helpers'))).toBe(false)

    const ignore = packaging('ignore')
    expect(ignore).toContain('test/**')
    expect(ignore).toContain('scripts/**')
    expect(ignore.some((e) => e.includes('.spec.js'))).toBe(false)
  })

  it('lets packaging.sh move the version, though it never ships in the XPI', () => {
    // The two lists answer different questions and this is the one place they
    // disagree. scripts/ is ignored by web-ext — it is repo tooling, not addon
    // code — but packaging.sh DECIDES the version string, and build.yml stamps
    // that string into the manifest.json that does ship. So changing how the
    // version is computed changes the shipped bytes.
    //
    // Excluding it from the pathspec was invisible while every push rebuilt the
    // web image. Milestone 313 step 4 made that rebuild conditional on the
    // derived revision moving, and the omission turned into a silent failure:
    // a new version means sign-extension misses its ext-<version> cache and
    // signs, while build-web sees an unmoved revision, reuses the published
    // image and ships the OLD XPI. An orphaned signature, and an instance
    // serving code the registry calls current.
    const pathspec = packaging('pathspec')
    expect(
      pathspec.some((e) => e.startsWith(':(exclude)extension/scripts')),
      'the pathspec excludes scripts/, so a change to how the version is '
        + 'derived would not move the version it derives',
    ).toBe(false)

    // ...and it is still kept out of the package itself. Both must hold: the
    // tempting "fix" for either half is to make the two lists one again.
    expect(packaging('ignore')).toContain('scripts')
  })

  it('keeps its own scripts and specs out of the XPI', () => {
    // Both are repo infrastructure. web-ext packages everything not ignored, so
    // omitting either would ship dev tooling to users -- and `test/**` in
    // particular only survives because callers `set -f` before substituting it.
    const ignore = packaging('ignore')
    expect(ignore).toContain('vitest.config.js')
    // Both forms per directory. The glob covers the contents; the bare name
    // covers the directory ENTRY, which web-ext writes separately — with only
    // the glob, the XPI carries an empty `test/` and `scripts/`.
    for (const dir of ['test', 'scripts']) {
      expect(ignore, `${dir} contents`).toContain(`${dir}/**`)
      expect(ignore, `${dir} directory entry`).toContain(dir)
    }
  })
})

describe('consumers delegate rather than keeping their own copy', () => {
  // These assertions are the actual anti-regression value: it is easy for a
  // future edit to "simplify" by inlining a literal list again, which silently
  // reintroduces the drift that issue #2397 was about.
  it('package.json derives --ignore-files from the script', () => {
    for (const [name, script] of Object.entries(read('package.json').scripts)) {
      if (!script.includes('--ignore-files')) continue
      expect(script, `${name} should call packaging.sh`).toContain('scripts/packaging.sh ignore')
      expect(script, `${name} must set -f before the substitution`).toMatch(/set -f;/)
    }
  })

  const WORKFLOWS = ['ci.yml', 'build.yml', 'extension.yml']

  it('no workflow hardcodes the packaged-file set', () => {
    // ci.yml used to substitute `packaging.sh pathspec` directly, for the
    // manual-bump guard that milestone 271 step 5 retired. Nothing inlines the
    // set today, and nothing should start to: a literal :(exclude)extension/...
    // in a workflow means someone bypassed the shared definition, which is
    // exactly the drift #2397 was about. Asserted across all three rather than
    // against one named consumer, so it keeps holding as consumers come and go.
    for (const wf of WORKFLOWS) {
      const text = readText('..', '.forgejo', 'workflows', wf)
      expect(text, `${wf} inlines an :(exclude) literal`).not.toMatch(/:\(exclude\)extension\//)
    }
  })

  it('build.yml takes the shipped version from the script, not from the repo', () => {
    // The version is DERIVED from commit time (#3092, milestone 271 step 4).
    // Going back to reading the committed value is not a style regression, it
    // is the bug: a hand-set version makes dev and main sign the same number
    // for different code, and the ext-<version> cache then serves one channel
    // the other's XPI.
    const build = readText('..', '.forgejo', 'workflows', 'build.yml')
    expect(build).toContain('packaging.sh version')
    expect(build, 'build.yml re-reads the committed version instead of deriving it')
      .not.toMatch(/grep[^\n]*'"version"'[^\n]*package\.json/)
  })
})

describe('extension version', () => {
  const majorMinor = (v) => v.split('.').slice(0, 2).join('.')

  it('keeps the hand-set MAJOR.MINOR in lockstep across both files', () => {
    // Narrowed from full-string equality at milestone 271 step 5. Since step 4
    // the patch component is derived from commit time and stamped into both
    // files at build time, so the committed patch numbers are inert — nothing
    // reads them and they are not what ships. Asserting on them would fail for
    // a difference that changes nothing.
    //
    // MAJOR.MINOR is the opposite: still hand-set, still shipped, and
    // packaging.sh reads it from manifest.json ALONE. Let the two diverge and
    // the extension ships a version package.json disagrees with, with no other
    // signal.
    expect(majorMinor(read('manifest.json').version))
      .toBe(majorMinor(read('package.json').version))
  })

  it('uses a plain dotted numeric version AMO will accept', () => {
    // The committed value seeds MAJOR.MINOR, so it still has to parse even
    // though its patch component never ships. ci.yml asserts the same shape on
    // the DERIVED value, which is the one AMO actually sees.
    expect(read('package.json').version).toMatch(/^\d+(\.\d+)*$/)
  })

  it('declares manifest v3', () => {
    expect(read('manifest.json').manifest_version).toBe(3)
  })

  it('lists every background script that exists, in dependency order', () => {
    // url.js must load BEFORE api.js: api.js calls normalizeApiUrl at
    // init()-time, and these are classic scripts sharing one scope, so a
    // reordering here is a runtime ReferenceError with no build-time signal.
    const scripts = read('manifest.json').background.scripts
    for (const rel of scripts) {
      expect(() => readFileSync(path.join(EXT_DIR, rel)), `missing ${rel}`).not.toThrow()
    }
    expect(scripts.indexOf('lib/url.js')).toBeLessThan(scripts.indexOf('lib/api.js'))
  })
})
