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

// packaged-cli-entry-guards.test.ts — **随包面**的 `.mjs` 不许手写入口守卫（[[INFRA-F140]] 的执行面）。
//
// 挂载层（本注释是挂载真源 —— [[INFRA-F126]] 纪律，⛔ 别去 grep 调用点推断）：
//   · L4 = .husky/pre-commit 第 8–9 行 `pnpm test`（vitest）—— ⚠️ 那两行**不在任何条件块里**，
//     即**每次 commit 无条件跑**，比本仓大多数条件触发的闸更强。
//   · L5 = .gitea/workflows/pr-checks.yml 第 67–68 行 `Unit tests (vitest)` → `pnpm test`
//     （pull_request + push:master 两条触发 ⇒ 满足 gate 平权）
//   ⛔ **刻意不做成 `scripts/audit-*.mjs`**，三条理由（⛔ 不是「测试写起来省事」）：
//     ① 本断言是**纯仓库形态断言** —— 没有 main()、没有 CLI 入口、没有「接线」可言，
//        而 `audit-gate-regression-ratchet` 的分母正是「blocking 闸的接线零覆盖条数」
//        ⇒ 造一条没有接线的 `audit-*` 去进那个分母，是给棘轮塞一个它度量不了的东西；
//     ② `scripts/{audit,smoke,check}-*.mjs` 是 `audit:gate-mount-declaration` 的扫描面
//        ⇒ 新增一条就要连带答「挂哪一层」，而它真正的挂载就是 vitest（见上）；
//     ③ `gate-regression-face` 一族现有三条（mount-declaration / regression-ratchet /
//        face-inventory）语义各是「挂载声明 / 接线覆盖 / 量具」，**没有一条管守卫形态**
//        ⇒ 无现成位置可挂，而本仓已点名「加规则不加闸会让 prose:gate 更差」。
//
// ─────────────────────────────────────────────────────────────────────────────
// WHY（[[INFRA-F140]]）
//
// `scripts/lib/is-cli-entry.mjs` 的头注释是六种错写法（F1–F6）的真源，逐字记着代价：
// 一条随包脚本若手写入口守卫，**经 symlink 调用时整条 CLI 不执行、退出码 0、stdout/stderr 双空**，
// 而 pnpm 的 `node_modules/@scope/pkg` 本身就是 symlink ⇒ 下游 CI 会**绿着漏**
// （实测：`MicroApps/vue-app`，2026-08-07 起绿了 19 天）。
//
// 此前这条纪律**只活在那份头注释里**：16 条随包脚本已全部迁到共用真源，
// 但「别再写回去」没有任何机制守 —— 新增一条随包脚本时靠写代码的人自觉。
// ⇒ 本测试把它变成机制。
//
// ⚠️ **射程（⛔ 别放大）**：本测试只管**随包面**。仓内非随包脚本上那 61 处手写守卫
// 按 [[INFRA-F138]] 逐字「刻意不含」在本治理面内（真源 = `tests/lib/gate-fixture-root.ts`
// 头注释「⛔ 那 14 条不是缺陷待修」那段）⇒ **⛔ 别拿本测试去扫它们**。
//
// 🟢 **它同时承载一条此前靠人记得的重开条件**：那批非随包脚本里若有哪条被加进 `files[]`，
// 它当场落进本判定面 ⇒ 本测试红。（`gate-fixture-root.ts` 逐字先写过：「一条用原始守卫的闸
// 若哪天被加进 `files[]`，本 harness 的绿**不能**当它的凭据」。）
//
// ─────────────────────────────────────────────────────────────────────────────
// 判据面口径（⛔ 引用本测试的读数必须带这个口径名）
//
//   `package.json` 的 `files[]` glob 展开 ∩ `git ls-files` ∩ 后缀 `.mjs`
//   ⛔ **不走 `npm pack --json`** —— 本仓实测它解析会抛（装置失败 ⛔ 不当读数）。
//   ⚠️ 因此本面是 **tracked 口径**：`dist/` `dist-wc/` 是构建产物、未跟踪 ⇒ 天然在面外
//      （它们也不含手写守卫的源码形态）。`eslint-plugin/` 是 `.js` CommonJS ⇒ 不涉及
//      `import.meta` ⇒ 同样在面外。**这是如实登记的边界，⛔ 不是待补 TODO。**
//
// 判据本体（⛔ 两条硬约束，真源头注释 2026-09-14 已逐字写进，别丢）：
//   1. **每种形态写成双向**（`a === b` 与 `b === a`）—— 只写单方向会漏左右颠倒的写法
//      （一次顶层面普查因此漏了 5 处）。
//   2. **扫描面先去注释再切，且只删纯注释行、⛔ 不删行尾注释** —— 共用真源自己的头注释
//      逐字含这六种错形态（不去注释会把它自己扫红），而形态 F2 的判据本体含 `file://`
//      （按 `//` 切行尾会把判据自己截断 ⇒ 假阴）。⛔ 也别用跨行块注释状态机：
//      实测它会被行注释里的 `/*` 永久卡住，之后整个文件被清空、分类静默给出一个
//      「看起来合理」的结果。
//
// ⚠️ **本判据测的是「源码里有没有这六种形态」，⛔ 不是「这条脚本的守卫行为对不对」**。
//   后者的真源是 `scripts/lib/is-cli-entry.mjs` + `tests/is-cli-entry.test.ts`（含真 symlink
//   的端到端用例）。两者不可互相冒充。
// -----------------------------------------------------------------------------

const __dirname = dirname(fileURLToPath(import.meta.url))
const ROOT = resolve(__dirname, '..')

// ─────────────────────────────────────────────────────────────────────────────
// 具名豁免表 —— **只许缩**（同 `KNOWN_SILENT` / `audit-layout-tokens` 范式）。
//
// 🔴 处方草案原写「存量实测 0 ⇒ 不需要豁免表」，那个读数量的是 `scripts/` 随包子集；
//    按 `files[]` **字面**口径展开后存量是 **3** —— `files[]` 里有一整行 `"templates"`，
//    而 `templates/` 从没被那次普查量过。本表是那 3 条的处置。
//
// **为什么是豁免而不是待修**（每条都有 lab 侧实测，⛔ 不是「看起来没事」）：
//   这 3 份**随包但不从 `node_modules` 跑** —— 它们是被 `cp` 到消费仓库根下再执行的模板：
//     · `SETUP_GUIDE.md:231` 逐字 `cp "$TEMPLATE/.githooks/$f" ".githooks/$f"`
//     · `SETUP_GUIDE.md:353` 逐字 `cp "$TEMPLATE/docs/scripts/build-request-index.mjs" …`
//     · 调用形态逐字 `node .githooks/audit-mockup-handoff-evidence.mjs`（`:262`）与
//       `node docs/scripts/build-request-index.mjs`（`docs/README.md:13`）—— **仓根相对，零 symlink**
//   ⇒ 与 [[INFRA-F138]] 判「那 14 条不是缺陷待修」**同构的理由**（不从 symlink 路径调用）。
//
// ⛔ **为什么不把 `templates/` 整个从判定面砍掉**：砍掉 ⇒ 将来 `templates/` 下新增一份
//    手写守卫的脚本时本测试**看不见**（fail-open）。留在面内 + 具名豁免 ⇒ 新增的会被抓到，
//    而这张表只许缩。
//
// **重开条件（可机检，⛔ 不是「以后再看」）**：这 3 份里任一出现**经 symlink 的调用路径**
//    （consumer 侧改成直接跑 `node_modules/.../templates/…`、或 scaffold 改 `cp` 为 `ln -s`）
//    ⇒ 该条当场从「刻意不治理」变成真缺陷，按共用真源的写法换掉。
//    ⚠️ 这两份 `.githooks/` 镜像是**零依赖自包含**的（消费仓无 npm 生态）
//    ⇒ ⛔ 不能 `import` DS 的 `is-cli-entry.mjs`，要换只能内联 realpath 双比。
const EXEMPT: ReadonlyMap<string, { form: string; why: string }> = new Map([
  [
    'templates/consumer-product/.githooks/audit-handoff-deliverable-sections.mjs',
    { form: 'F1', why: 'cp 到消费仓 .githooks/ 后由 pre-commit 以仓根相对路径调用，零 symlink' },
  ],
  [
    'templates/consumer-product/.githooks/audit-mockup-handoff-evidence.mjs',
    { form: 'F1', why: '同上（SETUP_GUIDE.md:231 cp · :262 node .githooks/<x>.mjs）' },
  ],
  [
    'templates/consumer-product/docs/scripts/build-request-index.mjs',
    { form: 'F5', why: 'cp 到消费仓 docs/scripts/ 后由人手跑 node docs/scripts/<x>.mjs，零 symlink' },
  ],
])

// ─────────────────────────────────────────────────────────────────────────────
// 判定面构造 —— fail closed：任何一层塌了即抛，⛔ 不静默降级成「0 命中」。

/** npm `files[]` 的一条 pattern → 匹配器。`!` 前缀为排除；无通配符且无后缀者按目录前缀。 */
function toMatcher(pat: string): { neg: boolean; re: RegExp } {
  const neg = pat.startsWith('!')
  const body = neg ? pat.slice(1) : pat
  const hasGlob = /[*?[\]]/.test(body)
  const esc = (s: string) => s.replace(/[.+^${}()|[\]\\]/g, '\\$&')
  if (!hasGlob && !/\.[a-z0-9]+$/i.test(body)) {
    return { neg, re: new RegExp(`^${esc(body)}/`) } // 目录：整个随包
  }
  const re =
    '^' +
    esc(body)
      .replace(/\*\*/g, '\u0000') // `**` 跨 `/`
      .replace(/\*/g, '[^/]*') // `*` 不跨 `/`
      .replace(/\u0000/g, '.*') +
    '$'
  return { neg, re: new RegExp(re) }
}

function packagedMjs(): string[] {
  const pkg = JSON.parse(readFileSync(resolve(ROOT, 'package.json'), 'utf8')) as { files?: string[] }
  const patterns = pkg.files
  if (!Array.isArray(patterns) || patterns.length === 0) {
    throw new Error('package.json files[] 为空或缺失 —— 判定面塌了（fail closed）')
  }
  const tracked = execFileSync('git', ['ls-files'], { cwd: ROOT, encoding: 'utf8' })
    .split('\n')
    .filter(Boolean)
  if (tracked.length < 100) {
    throw new Error(`git ls-files 只返回 ${tracked.length} 份 —— 判定面塌了（fail closed）`)
  }
  const matchers = patterns.map(toMatcher)
  const packaged = tracked.filter((f) => {
    let included = false
    for (const m of matchers) if (m.re.test(f)) included = !m.neg
    return included
  })
  return packaged.filter((f) => f.endsWith('.mjs'))
}

/**
 * 只删**纯注释行**（行首是 `//` / `*` / `/*`），⛔ 不删行尾注释。
 * ⛔ 刻意不做跨行块注释状态机（见头注释第 2 条硬约束）。
 */
function stripPureCommentLines(src: string): string {
  return src
    .split('\n')
    .filter((l) => !/^\s*(\/\/|\*|\/\*)/.test(l))
    .join('\n')
}

// ─────────────────────────────────────────────────────────────────────────────
// 六形态 —— 逐字取自 `scripts/lib/is-cli-entry.mjs` 头注释的 F1–F6，**每种双向**。
// ⛔ 别在这里发明形态：新形态先进那份真源的头注释，再同步过来。

const ARGV = String.raw`process\.argv\[1\]`
const RESOLVE_ARGV = String.raw`resolve\(\s*process\.argv\[1\]\s*\)`
const SELF_FUP = String.raw`fileURLToPath\(\s*import\.meta\.url\s*\)`

const FORMS: ReadonlyArray<{ id: string; left: string; right: string }> = [
  { id: 'F1', left: ARGV, right: SELF_FUP },
  {
    id: 'F2',
    left: String.raw`import\.meta\.url`,
    right: String.raw`\x60file://\$\{\s*process\.argv\[1\]\s*\}\x60`,
  },
  { id: 'F3', left: RESOLVE_ARGV, right: String.raw`resolve\(\s*${SELF_FUP}\s*\)` },
  { id: 'F4', left: ARGV, right: String.raw`new URL\(\s*import\.meta\.url\s*\)\.pathname` },
  {
    id: 'F5',
    left: String.raw`import\.meta\.url`,
    right: String.raw`pathToFileURL\(\s*process\.argv\[1\]\s*\)\.href`,
  },
  { id: 'F6', left: RESOLVE_ARGV, right: SELF_FUP },
]

/** 双向：`a === b` 与 `b === a` 都算命中。 */
const formRe = (f: { left: string; right: string }) =>
  new RegExp(`(?:${f.left}\\s*===\\s*${f.right})|(?:${f.right}\\s*===\\s*${f.left})`)

function hitForms(body: string): string[] {
  return FORMS.filter((f) => formRe(f).test(body)).map((f) => f.id)
}

const TRUTH_SOURCE = 'scripts/lib/is-cli-entry.mjs'

// ─────────────────────────────────────────────────────────────────────────────

describe('随包面的 .mjs 不许手写 CLI 入口守卫（INFRA-F140 的执行面）', () => {
  // ── 控制：判定面 ────────────────────────────────────────────────────────
  it('MH1 判定面非空 —— 随包 .mjs 数量在合理区间（0 或爆炸都说明 files[] 解析塌了）', () => {
    const face = packagedMjs()
    // 2026-09-14 现取 = 53。给区间而不是写死，避免正常增删脚本时误红；
    // ⛔ 但两端都要卡：0 = 解析塌了，>200 = glob 展开错成了全仓。
    expect(face.length, `随包 .mjs = ${face.length}，判定面异常`).toBeGreaterThanOrEqual(20)
    expect(face.length, `随包 .mjs = ${face.length}，判定面异常`).toBeLessThanOrEqual(200)
  })

  it('MH2 判定面确实含共用真源本体（否则下面那两条对照测的是空气）', () => {
    expect(packagedMjs()).toContain(TRUTH_SOURCE)
  })

  // ── 控制：形态串本身没写错 ──────────────────────────────────────────────
  it('MH3 六形态各自命中共用真源头注释里那六行字面（形态串写错就当场抛）', () => {
    const truth = readFileSync(resolve(ROOT, TRUTH_SOURCE), 'utf8')
    for (const f of FORMS) {
      expect(formRe(f).test(truth), `${f.id} 在 ${TRUTH_SOURCE} 头注释里零命中 ⇒ 形态串写错了`).toBe(
        true,
      )
    }
  })

  it('MH4 六形态**双向**都认得（只写单方向的判据一次普查漏过 5 处）', () => {
    // 合成左右颠倒的串：真源头注释里只有正向写法，反向只能合成来测。
    const reversed: Record<string, string> = {
      F1: 'if (fileURLToPath(import.meta.url) === process.argv[1]) main()',
      F2: 'if (`file://${process.argv[1]}` === import.meta.url) main()',
      F3: 'if (resolve(fileURLToPath(import.meta.url)) === resolve(process.argv[1])) main()',
      F4: 'if (new URL(import.meta.url).pathname === process.argv[1]) main()',
      F5: 'if (pathToFileURL(process.argv[1]).href === import.meta.url) main()',
      F6: 'if (fileURLToPath(import.meta.url) === resolve(process.argv[1])) main()',
    }
    for (const f of FORMS) {
      expect(hitForms(reversed[f.id]), `${f.id} 的反向写法没被认出来`).toContain(f.id)
    }
  })

  // ── 控制：去注释这一步有区分力（⛔ 不是恒真） ──────────────────────────
  it('MN1 去注释**后**共用真源零命中（它的判据本体是 realpath 双比，不含这六种）', () => {
    const raw = readFileSync(resolve(ROOT, TRUTH_SOURCE), 'utf8')
    expect(hitForms(stripPureCommentLines(raw))).toEqual([])
  })

  it('MN2 去注释**前**共用真源六种全中 —— 这是「去注释真的生效」的唯一凭据', () => {
    // ⛔ 不许把这条删掉换成只留 MN1：只断言「零命中」分不清
    //    「去注释生效了」与「六个形态串全写错了、对谁都不命中」。
    const raw = readFileSync(resolve(ROOT, TRUTH_SOURCE), 'utf8')
    expect(hitForms(raw).sort()).toEqual(['F1', 'F2', 'F3', 'F4', 'F5', 'F6'])
  })

  it('MN3 行尾注释**不得**被删 —— F2 的判据本体含 `file://`，按 `//` 切会把它截断成假阴', () => {
    const line = 'if (import.meta.url === `file://${process.argv[1]}`) main() // 入口守卫'
    expect(stripPureCommentLines(line)).toBe(line)
    expect(hitForms(stripPureCommentLines(line)), 'F2 被行尾注释截断了').toContain('F2')
  })

  it('MN4 主断言不是恒绿 —— 六条正向违例各自被抓到，而正确用法不被抓', () => {
    const positives: Record<string, string> = {
      F1: 'if (process.argv[1] === fileURLToPath(import.meta.url)) main()',
      F2: 'if (import.meta.url === `file://${process.argv[1]}`) main()',
      F3: 'if (resolve(process.argv[1]) === resolve(fileURLToPath(import.meta.url))) main()',
      F4: 'if (process.argv[1] === new URL(import.meta.url).pathname) main()',
      F5: 'if (import.meta.url === pathToFileURL(process.argv[1]).href) main()',
      F6: 'if (resolve(process.argv[1]) === fileURLToPath(import.meta.url)) main()',
    }
    for (const f of FORMS) {
      expect(hitForms(stripPureCommentLines(positives[f.id])), `${f.id} 的违例没被抓到`).toContain(
        f.id,
      )
    }
    // 阴性对照：共用真源的正确用法**不得**被判成违例
    expect(hitForms('if (isCliEntry(import.meta.url)) main()')).toEqual([])
  })

  // ── 控制：豁免表只许缩 ──────────────────────────────────────────────────
  it('MN5 豁免表里每条路径都还在（路径没了就该删表行，⛔ 别留 stale 豁免）', () => {
    for (const rel of EXEMPT.keys()) {
      expect(existsSync(resolve(ROOT, rel)), `豁免表里的 ${rel} 已不存在 ⇒ 删掉这一行`).toBe(true)
    }
  })

  it('MN6 豁免表只许缩 —— 某条已经改好就必须当场把它从表里删掉', () => {
    for (const [rel, meta] of EXEMPT) {
      const body = stripPureCommentLines(readFileSync(resolve(ROOT, rel), 'utf8'))
      const hits = hitForms(body)
      expect(
        hits,
        `${rel} 已不再手写入口守卫（现命中 ${JSON.stringify(hits)}）⇒ 把它从 EXEMPT 里删掉`,
      ).toContain(meta.form)
    }
  })

  // ── 主断言 ─────────────────────────────────────────────────────────────
  it('随包 .mjs 里不得出现 F1–F6 六种手写入口守卫（除具名豁免）', () => {
    const offenders: string[] = []
    for (const rel of packagedMjs()) {
      if (EXEMPT.has(rel)) continue
      const body = stripPureCommentLines(readFileSync(resolve(ROOT, rel), 'utf8'))
      const hits = hitForms(body)
      if (hits.length > 0) offenders.push(`${rel} → ${hits.join(',')}`)
    }
    expect(
      offenders,
      [
        '随包脚本手写了入口守卫 —— 经 symlink 调用时它会静默不执行（exit 0 + 双空输出）。',
        "改法：`import { isCliEntry } from './lib/is-cli-entry.mjs'` 后写",
        '`if (isCliEntry(import.meta.url)) { … }`（真源与六种错写法的说明见该文件头注释）。',
        '若它其实不从 node_modules 跑，把它加进本文件的 EXEMPT 并写清实测理由 + 重开条件。',
      ].join('\n'),
    ).toEqual([])
  })
})
