// tests/lint-skills.test.ts
// -----------------------------------------------------------------------------
// `lint-skills`（L5 gh-ci + L5 gate-chain）的**整脚本**回归面。
//
// 为什么是整脚本而不是 import 判据函数：该闸 107 行**零导出**、判据写在顶层的
// `// --- main ---` 段，结构上没有可 import 的符号。量具
// `pnpm report:gate-regression-face` 把它记为「零判据覆盖 · P1/P2 双 PASS」。
// 本文件是那条判定的兑现（[[INFRA-F138]]）。⛔ 闸本体一行没改。
//
// 覆盖：绿档非空过 + 断链致败 + 四类**不该校验**的链接形态（http / 锚点 / mailto / 剥锚点）
// + 两条解析路径（相对 SKILL.md 目录 · 相对 repo root）+ 目录发现的两个分支
// （`skills/` 优先 · `.claude/skills/` fallback）+ 4 条接线钉。
// -----------------------------------------------------------------------------
import { describe, it, expect, afterEach } from 'vitest'
import {
  createGateFixture,
  runGate,
  expectGateRed,
  expectGateGreen,
  cleanupGateFixtures,
} from './lib/gate-fixture-root'

const GATE = 'scripts/lint-skills.mjs'
/** fixture 专属 skill 名 —— 真仓库 skills/ 下不存在，输出里点名它 = 跑对了树的凭据。 */
const SKILL = 'fx-link-probe'

afterEach(cleanupGateFixtures)

function build(files: Record<string, string>, dirs: string[] = []) {
  return createGateFixture({ gate: GATE, prefix: 'lint-skills-fx', dirs, files })
}

/** 在 `skills/<SKILL>/SKILL.md` 放一份正文，并补齐它引用的目标文件。 */
function withSkill(body: string, extra: Record<string, string> = {}) {
  return build({
    [`skills/${SKILL}/SKILL.md`]: body,
    'docs/A.md': '# A\n',
    ...extra,
  })
}

describe('lint-skills — 绿档 + 自印非空过', () => {
  it('链接全部解析得到 → exit 0，且自印 fixture 自己的 skill 名与链接数（≠ 真仓库 ⇒ 非空过）', () => {
    const run = runGate(withSkill('见 [A](docs/A.md) 与 [同目录](./ref.md)\n', { [`skills/${SKILL}/ref.md`]: 'x' }), GATE)
    expectGateGreen(run, {
      contains: [
        'lint-skills — scanned 1 skill(s)',
        `✓ ${SKILL}  (2 link(s) — all present)`,
        'All skill doc links resolve. ✓',
      ],
    })
  })
})

describe('lint-skills — 判据：断链', () => {
  it('指向不存在的文件 → 红、逐条点名该链接、计数正确', () => {
    const run = runGate(withSkill('见 [没了](docs/GONE.md)\n'), GATE)
    expectGateRed(run, {
      marker: '1 broken link(s) found. Fix or remove them.',
      checks: [`✗ ${SKILL}  (1 missing):`, '· docs/GONE.md'],
    })
  })

  it('同一份 SKILL.md 只把 GONE 换成真实文件 → 绿（致败源是那一条，不是文档形态）', () => {
    const run = runGate(withSkill('见 [有](docs/A.md)\n'), GATE)
    expectGateGreen(run, { contains: [`✓ ${SKILL}  (1 link(s) — all present)`] })
  })

  it('同一份文档里重复引用同一断链只算一次（extractDocLinks 去重）', () => {
    const run = runGate(withSkill('[a](docs/GONE.md) 又 [b](docs/GONE.md)\n'), GATE)
    expectGateRed(run, { marker: '1 broken link(s) found.', checks: [`✗ ${SKILL}  (1 missing):`] })
  })
})

describe('lint-skills — ⛔ 不该校验的链接形态（must-not-hit）', () => {
  it('http / https 外链不校验', () => {
    const run = runGate(withSkill('[站](https://example.invalid/nope.md) [站2](http://example.invalid/x)\n'), GATE)
    expectGateGreen(run, { contains: [`✓ ${SKILL}  (0 link(s) — all present)`] })
  })

  it('纯锚点 `#section` 不校验', () => {
    const run = runGate(withSkill('[本页](#some-section)\n'), GATE)
    expectGateGreen(run, { contains: [`✓ ${SKILL}  (0 link(s)` ] })
  })

  it('mailto: 不校验', () => {
    const run = runGate(withSkill('[写信](mailto:x@example.invalid)\n'), GATE)
    expectGateGreen(run, { contains: [`✓ ${SKILL}  (0 link(s)` ] })
  })

  it('带锚点的仓库路径先剥锚点再校验（`docs/A.md#x` 命中的是 docs/A.md）', () => {
    const run = runGate(withSkill('[A 的某段](docs/A.md#some-heading)\n'), GATE)
    expectGateGreen(run, { contains: [`✓ ${SKILL}  (1 link(s) — all present)`] })
  })

  it('⛔ must-HIT 对照：剥了锚点之后仍不存在的路径照样报（剥锚点不等于放行）', () => {
    const run = runGate(withSkill('[没了的某段](docs/GONE.md#some-heading)\n'), GATE)
    expectGateRed(run, { checks: ['· docs/GONE.md'] })
  })
})

describe('lint-skills — 两条解析路径', () => {
  it('相对 SKILL.md 自己的目录解析（`./ref.md`）', () => {
    const run = runGate(withSkill('[近](./ref.md)\n', { [`skills/${SKILL}/ref.md`]: 'x' }), GATE)
    expectGateGreen(run, { contains: [`✓ ${SKILL}  (1 link(s) — all present)`] })
  })

  it('相对 repo root 解析（`docs/A.md` —— 从 skill 目录算是不存在的）', () => {
    const run = runGate(withSkill('[远](docs/A.md)\n'), GATE)
    expectGateGreen(run, { contains: [`✓ ${SKILL}  (1 link(s) — all present)`] })
  })

  it('前导斜杠 `/docs/A.md` 按 repo root 解析', () => {
    const run = runGate(withSkill('[根](/docs/A.md)\n'), GATE)
    expectGateGreen(run, { contains: [`✓ ${SKILL}  (1 link(s) — all present)`] })
  })
})

describe('lint-skills — 目录发现的两个分支', () => {
  it('`skills/` 存在时用它（此时 .claude/skills 下的断链**不**被扫）', () => {
    const run = runGate(
      build({
        [`skills/${SKILL}/SKILL.md`]: '[ok](docs/A.md)\n',
        'docs/A.md': '# A\n',
        '.claude/skills/other/SKILL.md': '[断](docs/NOPE.md)\n',
      }),
      GATE,
    )
    expectGateGreen(run, { contains: ['scanned 1 skill(s)', `✓ ${SKILL}`] })
    expect(run.stdout).not.toContain('other')
  })

  it('`skills/` 不存在时 fallback 到 `.claude/skills/`（断链照样抓）', () => {
    const run = runGate(
      build({ '.claude/skills/fx-fallback/SKILL.md': '[断](docs/NOPE.md)\n' }),
      GATE,
    )
    expectGateRed(run, { checks: ['scanned 1 skill(s)', '✗ fx-fallback  (1 missing):', '· docs/NOPE.md'] })
  })

  it('skills/ 下的**文件**（非目录）被跳过，不当 skill 数', () => {
    const run = runGate(
      build({ [`skills/${SKILL}/SKILL.md`]: '[ok](docs/A.md)\n', 'docs/A.md': '# A\n', 'skills/README.md': '# not a skill\n' }),
      GATE,
    )
    expectGateGreen(run, { contains: ['scanned 1 skill(s)'] })
  })
})

describe('lint-skills — 接线钉（判据 → 退出码 → 点名输出）', () => {
  it('多个 skill 各有断链 → 全部逐条印出、totalMissing 跨 skill 累加（接线不吞）', () => {
    const run = runGate(
      build({
        [`skills/${SKILL}/SKILL.md`]: '[x](docs/G1.md)\n',
        'skills/fx-link-probe-2/SKILL.md': '[y](docs/G2.md) [z](docs/G3.md)\n',
      }),
      GATE,
    )
    expect(run.status).toBe(1)
    expect(run.stdout).toContain('scanned 2 skill(s)')
    expect(run.stdout).toContain('· docs/G1.md')
    expect(run.stdout).toContain('· docs/G2.md')
    expect(run.stdout).toContain('· docs/G3.md')
    expect(run.stdout).toContain('3 broken link(s) found.')
  })

  it('一绿一红混合 → 绿的印 ✓、红的印 ✗，退出码由红的决定', () => {
    const run = runGate(
      build({
        [`skills/${SKILL}/SKILL.md`]: '[ok](docs/A.md)\n',
        'docs/A.md': '# A\n',
        'skills/fx-link-probe-2/SKILL.md': '[bad](docs/G2.md)\n',
      }),
      GATE,
    )
    expect(run.status).toBe(1)
    expect(run.stdout).toContain(`✓ ${SKILL}  (1 link(s) — all present)`)
    expect(run.stdout).toContain('✗ fx-link-probe-2  (1 missing):')
  })

  it('skills 目录整个不存在 → exit 1 + 报出找的是哪个路径（判据式 fail-closed，⛔ 不是崩溃）', () => {
    const root = createGateFixture({ gate: GATE, prefix: 'lint-skills-fx-nodir' })
    const run = runGate(root, GATE)
    expectGateRed(run, { marker: 'Skills directory not found:' })
    expect(run.stderr).not.toContain('ENOENT')
  })

  it('目录在但一个 SKILL.md 都没有 → exit 0（如实登记的空分母面）', () => {
    // ⚠️ 这条钉的是**现行行为**，不是背书：0 份 SKILL.md 时闸静默绿。
    // 与上一条对比即本条的意义 —— 目录缺失 fail-closed，目录空则放行，两者判据不同。
    // ⛔ 别把这条读成「分母塌了没关系」；要改成 fail-closed 得先立项（同 F138 不顺手改闸的纪律）。
    const run = runGate(build({ 'skills/not-a-skill/README.md': 'x\n' }), GATE)
    expectGateGreen(run, { contains: ['No SKILL.md files found.'] })
  })
})
