// tests/upstream-gate.test.ts
// 支柱④ upstream-gate validator 纯函数单测（zero-dep validator，对齐 stale-anchors 范式）。
import { describe, it, expect } from 'vitest'
import { resolve } from 'node:path'
import {
  parseFrontmatter,
  loadSchema,
  validateStructure,
  checkSourceAnchor,
  validateCompetitiveItems,
  checkUrl,
  checkDataFeasibility,
  buildDegradationNote,
  findArtifacts,
  runGate,
} from '../scripts/validate-upstream-gate.mjs'

describe('parseFrontmatter', () => {
  it('解析标量 + JSON-inline 复杂字段', () => {
    const text = [
      '---',
      'feature: live-dashboard',
      'sources_read: ["docs/PROJECT_GOAL.md#一句话目标", "docs/STATUS.md"]',
      'competitive: [{"vendor":"LiveU","url":"https://liveu.tv","finding":"has X"}]',
      'data_feasibility: {"fields": ["bitrate", "latency"]}',
      'understanding: 用户要一个实时看板',
      '---',
      '正文段落',
    ].join('\n')
    const { data, body } = parseFrontmatter(text)
    expect(data.feature).toBe('live-dashboard')
    expect(data.sources_read).toEqual(['docs/PROJECT_GOAL.md#一句话目标', 'docs/STATUS.md'])
    expect(data.competitive[0].vendor).toBe('LiveU')
    expect(data.data_feasibility.fields).toEqual(['bitrate', 'latency'])
    expect(data.understanding).toBe('用户要一个实时看板')
    expect(body.trim()).toBe('正文段落')
  })

  it('支持复杂字段跨多行（续行拼接后 JSON.parse）', () => {
    const text = [
      '---',
      'competitive: [',
      '  {"vendor":"Dejero","url":"https://dejero.com","finding":"y"}',
      ']',
      'feature: x',
      '---',
    ].join('\n')
    const { data } = parseFrontmatter(text)
    expect(data.competitive[0].vendor).toBe('Dejero')
    expect(data.feature).toBe('x')
  })

  it('字符串值内含未配对括号不误判续行（competitive finding 常见，回归 I1）', () => {
    // "finding" 里的孤立 `[` 会让括号计数误判未配平；JSON.parse 试探法能正确处理。
    const text = [
      '---',
      'competitive: [{"vendor":"X","url":"https://x.com","finding":"open bracket ["}]',
      'feature: y',
      '---',
    ].join('\n')
    const { data } = parseFrontmatter(text)
    expect(data.competitive[0].finding).toBe('open bracket [')
    expect(data.feature).toBe('y')
  })

  it('无 front-matter 抛错', () => {
    expect(() => parseFrontmatter('# 只有正文\n没有 front-matter')).toThrow()
  })
})

describe('validateStructure', () => {
  const schema = loadSchema()

  it('全部必填字段存在且类型正确 → 全 ok', () => {
    const data = {
      feature: 'x',
      understanding: 'y',
      sources_read: ['a.md'],
      competitive: [],
      persona_ia: 'p',
      data_feasibility: { fields: [] },
      mvp_scope: 'm',
    }
    const results = validateStructure(data, schema)
    expect(results.every((r) => r.ok)).toBe(true)
  })

  it('缺必填字段 → 该字段 ok=false', () => {
    const data = { feature: 'x' }
    const results = validateStructure(data, schema)
    const understanding = results.find((r) => r.field === 'understanding')
    expect(understanding?.ok).toBe(false)
  })

  it('类型不符（sources_read 应 array 却给 string）→ ok=false', () => {
    const data = {
      feature: 'x', understanding: 'y', sources_read: 'not-array',
      competitive: [], persona_ia: 'p', data_feasibility: { fields: [] }, mvp_scope: 'm',
    }
    const results = validateStructure(data, schema)
    expect(results.find((r) => r.field === 'sources_read')?.ok).toBe(false)
  })
})

describe('checkSourceAnchor', () => {
  const repoRoot = resolve(__dirname, '..')
  const opts = { repoRoot, artifactDir: repoRoot }

  it('存在的文件 + 存在的 heading slug → ok', () => {
    // docs/PROJECT_GOAL.md 有 "## 一句话目标" → slug 一句话目标
    const r = checkSourceAnchor('docs/PROJECT_GOAL.md#一句话目标', opts)
    expect(r.ok).toBe(true)
  })

  it('存在的文件 + 不存在的 slug → ok=false', () => {
    const r = checkSourceAnchor('docs/PROJECT_GOAL.md#这个标题不存在xyz', opts)
    expect(r.ok).toBe(false)
    expect(r.detail).toMatch(/slug/)
  })

  it('不存在的文件 → ok=false', () => {
    const r = checkSourceAnchor('docs/does-not-exist.md#x', opts)
    expect(r.ok).toBe(false)
    expect(r.detail).toMatch(/文件/)
  })

  it('无 anchor 的纯路径只校文件存在 → ok', () => {
    const r = checkSourceAnchor('docs/STATUS.md', opts)
    expect(r.ok).toBe(true)
  })
})

describe('validateCompetitiveItems', () => {
  it('齐全 → ok', () => {
    const r = validateCompetitiveItems([{ vendor: 'LiveU', url: 'https://liveu.tv', finding: 'x' }])
    expect(r[0].ok).toBe(true)
  })
  it('缺 finding → ok=false', () => {
    const r = validateCompetitiveItems([{ vendor: 'LiveU', url: 'https://liveu.tv' }])
    expect(r[0].ok).toBe(false)
  })
})

describe('checkUrl', () => {
  it('200 → PASS', async () => {
    const r = await checkUrl('https://liveu.tv', async () => ({ ok: true, status: 200 }) as any)
    expect(r.verdict).toBe('PASS')
  })
  it('404 → FAIL', async () => {
    const r = await checkUrl('https://liveu.tv/dead', async () => ({ ok: false, status: 404 }) as any)
    expect(r.verdict).toBe('FAIL')
  })
  it('网络异常/超时 → UNVERIFIED（不脑补 PASS）', async () => {
    const r = await checkUrl('https://liveu.tv', async () => { throw new Error('aborted') })
    expect(r.verdict).toBe('UNVERIFIED')
  })
  it('无效 URL → FAIL（不发请求）', async () => {
    let called = false
    const r = await checkUrl('not-a-url', async () => { called = true; return { ok: true, status: 200 } as any })
    expect(r.verdict).toBe('FAIL')
    expect(called).toBe(false)
  })
  it('占位域名 example.com → FAIL（不发请求）', async () => {
    const r = await checkUrl('https://example.com/x', async () => ({ ok: true, status: 200 }) as any)
    expect(r.verdict).toBe('FAIL')
  })
  it('HEAD 返 405 → fallback GET 200 → PASS（回归 fallback 分支）', async () => {
    let calls = 0
    const r = await checkUrl('https://liveu.tv', async (_url: string, opts: any) => {
      calls++
      if (opts?.method === 'HEAD') return { ok: false, status: 405 } as any
      return { ok: true, status: 200 } as any
    })
    expect(r.verdict).toBe('PASS')
    expect(calls).toBe(2)
  })
  it('HEAD throw → fallback GET 200 → PASS（回归 fallback 分支）', async () => {
    const r = await checkUrl('https://liveu.tv', async (_url: string, opts: any) => {
      if (opts?.method === 'HEAD') throw new Error('connect refused')
      return { ok: true, status: 200 } as any
    })
    expect(r.verdict).toBe('PASS')
  })
})

describe('checkDataFeasibility', () => {
  it('无 product-context（null）→ SKIP，不 FAIL', () => {
    const r = checkDataFeasibility(['bitrate'], null)
    expect(r.verdict).toBe('SKIP')
  })
  it('字段全在 context → PASS', () => {
    const r = checkDataFeasibility(['bitrate', 'latency'], ['bitrate', 'latency', 'x'])
    expect(r.verdict).toBe('PASS')
  })
  it('字段有缺集 → FAIL + 列出缺集', () => {
    const r = checkDataFeasibility(['bitrate', 'ghost_field'], ['bitrate'])
    expect(r.verdict).toBe('FAIL')
    expect(r.missing).toEqual(['ghost_field'])
  })
})

describe('buildDegradationNote', () => {
  it('汇总 SKIP/UNVERIFIED 条目成文本', () => {
    const note = buildDegradationNote([
      { check: 'data_feasibility', status: 'SKIP', reason: '无 product-context' },
      { check: 'competitive[0].url', status: 'UNVERIFIED', reason: '超时' },
    ])
    expect(note).toMatch(/data_feasibility/)
    expect(note).toMatch(/SKIP/)
    expect(note).toMatch(/UNVERIFIED/)
  })
  it('无降级条目 → 空串', () => {
    expect(buildDegradationNote([])).toBe('')
  })
})

describe('findArtifacts', () => {
  it('模板文件 _upstream-gate.template.md 不算 artifact', () => {
    // templates/consumer-product/docs/specs/_upstream-gate.template.md 存在但应被排除
    const repoRoot = resolve(__dirname, '..')
    const hits = findArtifacts(repoRoot)
    expect(hits.some((h) => h.includes('_upstream-gate.template.md'))).toBe(false)
  })
})

describe('runGate 端到端（DoD #2/#4）', () => {
  const repoRoot = resolve(__dirname, '..')
  const fx = resolve(repoRoot, 'scripts/__fixtures__/upstream-gate')
  // 注入假 fetch，200 OK，不依赖真实网络
  const fakeFetchOk = async () => ({ ok: true, status: 200 }) as any

  it('pass fixture → 确定层 PASS', async () => {
    const r = await runGate(resolve(fx, 'pass.md'), { repoRoot, fetchImpl: fakeFetchOk })
    expect(r.verdict).toBe('PASS')
  })

  it('缺必填字段 → FAIL（DoD #4）', async () => {
    const r = await runGate(resolve(fx, 'missing-field.md'), { repoRoot, fetchImpl: fakeFetchOk })
    expect(r.verdict).toBe('FAIL')
    expect(r.findings.some((f) => f.check.includes('mvp_scope'))).toBe(true)
    expect(r.findings.some((f) => f.check.includes('persona_ia'))).toBe(true)
  })

  it('死锚点 + 占位 URL → FAIL（DoD #4）', async () => {
    const r = await runGate(resolve(fx, 'dead-anchor-and-url.md'), { repoRoot, fetchImpl: fakeFetchOk })
    expect(r.verdict).toBe('FAIL')
    expect(r.findings.some((f) => f.check.startsWith('sources_read'))).toBe(true)
    expect(r.findings.some((f) => f.check.includes('competitive[0].url'))).toBe(true)
  })

  it('缺 product-context 时 data_feasibility SKIP 不 FAIL（DoD #2 降级）', async () => {
    // no-context-ok.md 的 data field 'some_field' 不在真 repo 任何 product-context；
    // 真 repo 根跑 findProductContext 应返回 null（repo 内无确切名 product-context.md）→ SKIP。
    const r = await runGate(resolve(fx, 'no-context-ok.md'), { repoRoot, fetchImpl: fakeFetchOk })
    expect(r.verdict).toBe('PASS')
    expect(r.degradationNote).toMatch(/SKIP/)
  })
})
