// tests/docs-section-anchor.test.ts
//
// INFRA-F90 —— docs 站 URL 的段落锚点。
//
// 修复前的实测基线（本文件第一组用例把它钉死成防回归）：`#/component/button/button-api`
// **不是**「落到 button 页但不滚动」，而是**整条掉到默认页 overview** —— 因为组件路由的
// 正则以 `$` 收尾，两段 URL 压根匹配不到它。所以本条修的不只是"不滚动"，是"链接根本没用"。
//
// 形态选择：段落 slug 是**路由后的一个尾段**（`#/component/button/button-api`），不是
// 第二个 `#`。本站是 hash 路由、逻辑路由本身住在 fragment 里，一个 URL 只有一个 `#`。
// 尾段是本仓库**已有的先例** —— `#/changelog/0.10.1` 的尾段就是给页面拿去滚动的版本号，
// 因此 changelog 刻意**不**支持段锚点（尾段已被占用），本文件有一条用例专门钉这个边界。
import { describe, it, expect } from 'vitest'
import { readFileSync } from 'node:fs'
import { resolve, dirname } from 'node:path'
import { fileURLToPath } from 'node:url'
import {
  getPageIdFromLocation,
  getPagePath,
  getSectionSlugFromLocation,
  slugifySectionTitle,
  defaultPageId,
} from '../playground/docs/navigation'

const HERE = dirname(fileURLToPath(import.meta.url))
const loc = (hash: string) => ({ hash, pathname: '/', search: '' }) as unknown as Location

// ---------------------------------------------------------------------------
// 防回归：修复前这些 URL 全部掉到默认页
// ---------------------------------------------------------------------------
describe('带段落尾段的 URL 必须落到正确页面（修复前掉到 overview）', () => {
  it('component 两段路由 → 对应组件页，不是默认页', () => {
    expect(getPageIdFromLocation(loc('#/component/button/button-api'))).toBe('button')
    expect(getPageIdFromLocation(loc('#/component/button/button-api'))).not.toBe(defaultPageId)
  })

  it('单段路由不受影响（原行为）', () => {
    expect(getPageIdFromLocation(loc('#/component/button'))).toBe('button')
    expect(getPageIdFromLocation(loc('#/'))).toBe(defaultPageId)
  })

  it('别名在两段形态下同样解析 —— 别名表只有一份，两条路径共用', () => {
    expect(getPageIdFromLocation(loc('#/component/input-line/anything'))).toBe('input')
    expect(getPageIdFromLocation(loc('#/component/select-filled/anything'))).toBe('select')
    expect(getPageIdFromLocation(loc('#/component/datetime-line/anything'))).toBe('datetime')
    // 单段形态仍然对（防止"只改了一处"）
    expect(getPageIdFromLocation(loc('#/component/input-line'))).toBe('input')
    expect(getPageIdFromLocation(loc('#/component/select-filled'))).toBe('select')
  })

  it('未知页面 + 尾段 → 仍回默认页（不能因为多一段就乱认）', () => {
    expect(getPageIdFromLocation(loc('#/component/not-a-real-page/whatever'))).toBe(defaultPageId)
  })

  it('changelog 的版本尾段行为一个字没变', () => {
    expect(getPageIdFromLocation(loc('#/changelog/0.10.1'))).toBe('changelog')
    expect(getPageIdFromLocation(loc('#/changelog'))).toBe('changelog')
  })
})

// ---------------------------------------------------------------------------
// slug 取值
// ---------------------------------------------------------------------------
describe('getSectionSlugFromLocation', () => {
  it('component / internal 两段路由取到尾段', () => {
    expect(getSectionSlugFromLocation(loc('#/component/button/button-api'))).toBe('button-api')
    expect(getSectionSlugFromLocation(loc('#/internal/a11y-report/summary'))).toBe('summary')
  })

  it('单段路由 = 无段落锚点', () => {
    expect(getSectionSlugFromLocation(loc('#/component/button'))).toBe('')
    expect(getSectionSlugFromLocation(loc('#/'))).toBe('')
    expect(getSectionSlugFromLocation(loc(''))).toBe('')
  })

  it('⛔ changelog 的尾段是版本号、不是段落 slug —— 必须取不到', () => {
    // 取到了就会去找一个叫 "0.10.1" 的段、找不到、再把 URL 当段锚点理解，
    // 把已有能力（版本滚动）和新能力搅在一起。
    expect(getSectionSlugFromLocation(loc('#/changelog/0.10.1'))).toBe('')
  })
})

// ---------------------------------------------------------------------------
// slugify 口径
// ---------------------------------------------------------------------------
describe('slugifySectionTitle', () => {
  it('基本形态', () => {
    expect(slugifySectionTitle('Button API')).toBe('button-api')
    expect(slugifySectionTitle('  Mixed  Case!!  ')).toBe('mixed-case')
    expect(slugifySectionTitle('Status Matrix (QA)')).toBe('status-matrix-qa')
  })

  it('全非 ASCII 标题 → 空串（由调用方回落到序号，见 DocsShell）', () => {
    expect(slugifySectionTitle('按钮')).toBe('')
  })

  it('⚠️ locale 相关是**已知且如实登记**的降级，不是 bug', () => {
    // 标题是 t('Button API', 'Button API 按钮 API')，中文 locale 下文本不同 → slug 不同。
    // 后果 = 跨 locale 的段链接不解析，用户停在页面顶部（= 修复前的行为，良性降级）。
    // 这条用例存在的意义是：谁哪天想"修"它，先看到这里写着这是有意接受的。
    expect(slugifySectionTitle('Button API')).not.toBe(slugifySectionTitle('Button API 按钮 API'))
  })
})

// ---------------------------------------------------------------------------
// 与视觉闸的 slug 口径漂移闸
// ---------------------------------------------------------------------------
describe('slug 口径必须与 INFRA-F88 视觉闸一致', () => {
  // 为什么是文本断言而不是 import：视觉闸真正干活的那份 slugify 写在
  // `page.evaluate()` 里 —— 浏览器上下文，引用不到 Node 侧的函数，结构上无法 import。
  // 而两边一旦分叉，「闸给这段起的名字」和「URL 里这段的名字」就对不上，
  // 用签核链接指位置这件事会静默失准。所以退而求其次：把两条正则字面量钉住。
  // ⚠️ 覆盖面如实声明：这只拦得住"改了正则"，拦不住"整段重写成等价但不同的实现"。
  const spec = readFileSync(resolve(HERE, 'visual/docs-pages.spec.ts'), 'utf8')

  it('视觉闸里仍是同两条正则', () => {
    expect(spec).toContain('.replace(/[^a-z0-9]+/g,')
    expect(spec).toContain(".replace(/^-|-$/g, '')")
    expect(spec).toContain('.toLowerCase()')
  })

  it('本文件这份实现对同样输入给同样结果（人工对照锚点）', () => {
    // 直接照搬视觉闸的三步在本地复算一遍，与导出的实现比对
    const mirror = (t: string) =>
      t.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '')
    for (const sample of ['Button API', 'Status Matrix (QA)', 'Design Spec', '  x  ', 'A—B']) {
      expect(slugifySectionTitle(sample)).toBe(mirror(sample))
    }
  })
})

// ---------------------------------------------------------------------------
// 「上手」两页：guide 路由前缀（2026-08-13）
//
// 为什么另起一个前缀而不复用 `component`：`#/component/onboarding` 会主动误导
// （它不是组件），而这个 URL 是要被转发出去的公开面。顶栏 `topNavigation` 里
// **早就有**一个 key 为 `guide` 的 tab（原先只指 overview），所以这不是新造概念，
// 是把"指南"这一面从 1 页扩到 3 页。
// ---------------------------------------------------------------------------
describe('guide 前缀路由（上手两页）', () => {
  it('单段 → 上手页', () => {
    expect(getPageIdFromLocation(loc('#/guide/onboarding'))).toBe('onboarding')
    expect(getPageIdFromLocation(loc('#/guide/for-developers'))).toBe('for-developers')
  })

  it('两段（带段落 slug）→ 仍落到上手页，不掉默认页', () => {
    expect(getPageIdFromLocation(loc('#/guide/onboarding/clone'))).toBe('onboarding')
    expect(getPageIdFromLocation(loc('#/guide/onboarding/clone'))).not.toBe(defaultPageId)
    expect(getPageIdFromLocation(loc('#/guide/for-developers/develop'))).toBe('for-developers')
  })

  it('段落 slug 取到尾段 —— 不把 guide 加进 SECTION_ANCHOR_PREFIXES 这条就是空串', () => {
    expect(getSectionSlugFromLocation(loc('#/guide/onboarding/clone'))).toBe('clone')
    expect(getSectionSlugFromLocation(loc('#/guide/for-developers/develop'))).toBe('develop')
    expect(getSectionSlugFromLocation(loc('#/guide/onboarding'))).toBe('')
  })

  it('未知 guide 页 + 尾段 → 回默认页（不能因为多一段就乱认）', () => {
    expect(getPageIdFromLocation(loc('#/guide/not-a-real-guide/whatever'))).toBe(defaultPageId)
    expect(getPageIdFromLocation(loc('#/guide/not-a-real-guide'))).toBe(defaultPageId)
  })

  it('getPagePath 给 guide 前缀，且组件页原行为一个字没变', () => {
    expect(getPagePath('onboarding')).toBe('/guide/onboarding')
    expect(getPagePath('for-developers')).toBe('/guide/for-developers')
    expect(getPagePath('button')).toBe('/component/button')
    expect(getPagePath('overview')).toBe('/')
    expect(getPagePath('changelog')).toBe('/changelog')
    expect(getPagePath('a11y-report')).toBe('/internal/a11y-report')
  })
})

// ---------------------------------------------------------------------------
// 段落 slug 表 —— 可分享链接整个建在这张表上
//
// 这一页的正文是中文，而 `slugifySectionTitle` 把非 [a-z0-9] 全替换掉 ⇒ 纯中文标题
// 会得到**空串**，由 `DocsShell` 兜底成位置式 `section-N`。位置 slug 一插段就整体位移、
// 已发出的链接指错段（正是 F90 注释点名要避免的）。所以这两页的段标题**刻意**带英文
// token 在前，且两个 locale 用同一个字符串 —— 换来的额外好处是这两页的段链接
// **跨 locale 也解析**（现有 33 页都做不到）。
//
// 本组用例是那张表的机械钉子：页面里的 h2 一改，这里就红。
// ---------------------------------------------------------------------------
describe('上手两页的段落 slug 必须与已发布的表逐字一致', () => {
  const readTitles = (file: string) => {
    const source = readFileSync(resolve(HERE, '..', file), 'utf8')
    return [...source.matchAll(/<h2 class="docs-section__title">([^<]+)<\/h2>/g)].map((m) =>
      m[1].trim(),
    )
  }

  const EXPECTED_ONBOARDING = [
    'method',
    'triage',
    'install-claude-code',
    'clone',
    'plugin',
    'claude-design',
    'first-words',
    'say-clearly',
    'correct',
    'accept',
    'wrap-up',
    'stuck',
    'browse-docs',
  ]

  const EXPECTED_FOR_DEVELOPERS = ['install-npm-import', 'develop', 'appendix']

  it('onboarding 页 13 段，slug 逐字符合', () => {
    const slugs = readTitles('playground/docs/pages/OnboardingPage.vue').map(slugifySectionTitle)
    expect(slugs).toEqual(EXPECTED_ONBOARDING)
  })

  it('for-developers 页 3 段，slug 逐字符合', () => {
    const slugs = readTitles('playground/docs/pages/ForDevelopersPage.vue').map(slugifySectionTitle)
    expect(slugs).toEqual(EXPECTED_FOR_DEVELOPERS)
  })

  it('16 个 slug 全唯一、无空串（空串 = 退化成位置式 section-N）', () => {
    const all = [...EXPECTED_ONBOARDING, ...EXPECTED_FOR_DEVELOPERS]
    expect(all.filter((s) => !s)).toEqual([])
    expect(new Set(all).size).toBe(all.length)
  })

  it('段标题不走 t(en, zh) —— 两个 locale 同一个字符串是本页段锚点跨 locale 可用的前提', () => {
    for (const file of [
      'playground/docs/pages/OnboardingPage.vue',
      'playground/docs/pages/ForDevelopersPage.vue',
    ]) {
      const source = readFileSync(resolve(HERE, '..', file), 'utf8')
      const titleLines = source
        .split('\n')
        .filter((line) => line.includes('docs-section__title'))
      expect(titleLines.length).toBeGreaterThan(0)
      for (const line of titleLines) {
        expect(line).not.toMatch(/\bt\(/)
        expect(line).not.toContain('{{')
      }
    }
  })
})

// ---------------------------------------------------------------------------
// 重定向壳 —— 老 URL 不能因为搬家而坏掉
//
// 壳里那三张表（SECTION / LEGACY / MOVED）的**值**必须是上面那批真实 slug。
// 打错一个字母 = 一条历史链接静默落到页面顶部而不是目标段，而人眼看不出来
// （页面照样打开、只是位置不对）。所以在这里机械核。
// ---------------------------------------------------------------------------
describe('静态页重定向壳的目标 slug 必须真实存在', () => {
  const KNOWN = new Set([
    'method',
    'triage',
    'install-claude-code',
    'clone',
    'plugin',
    'claude-design',
    'first-words',
    'say-clearly',
    'correct',
    'accept',
    'wrap-up',
    'stuck',
    'browse-docs',
    'install-npm-import',
    'develop',
    'appendix',
  ])

  // 壳里的 URL 是拼出来的（`'./#/guide/onboarding' + (slug ? '/' + slug : '')`），
  // 所以判据落在**三张表的值**上，不是找字面完整 URL：
  //   SECTION / MOVED 的值 = 段落 slug ⇒ 必须真实存在
  //   LEGACY 的值 = 今天的 section id ⇒ 必须是 SECTION 的键（否则压平后查不到，静默落页顶）
  const parseTable = (source: string, name: string) => {
    const start = source.indexOf(`var ${name} = {`)
    if (start === -1) return null
    const end = source.indexOf('};', start)
    const body = source.slice(start, end)
    const entries = [...body.matchAll(/'([^']+)'\s*:\s*'([^']+)'/g)]
    return {
      keys: entries.map((m) => m[1]),
      values: entries.map((m) => m[2]),
    }
  }

  it('onboarding 壳：SECTION / MOVED 的值全是真实 slug，LEGACY 的值全能压平到 SECTION 键', () => {
    const source = readFileSync(resolve(HERE, '../playground/public/onboarding.html'), 'utf8')
    const section = parseTable(source, 'SECTION')
    const legacy = parseTable(source, 'LEGACY')
    const moved = parseTable(source, 'MOVED')

    expect(section).not.toBeNull()
    expect(legacy).not.toBeNull()
    expect(moved).not.toBeNull()

    // 13 段 + 20 条历史代号 + 16 条跨页。⚠️ 20 是**数出来的**：本项 spec/plan 初稿写「21」，
    // 那是没数就写的，被这条断言当场抓住（原壳的 LEGACY 表逐条数 = 20）。条数变了要有人知道。
    expect(section!.keys.length).toBe(13)
    expect(legacy!.keys.length).toBe(20)
    expect(moved!.keys.length).toBe(16)

    expect(section!.values.filter((v) => !KNOWN.has(v))).toEqual([])
    expect(moved!.values.filter((v) => !KNOWN.has(v))).toEqual([])
    expect(legacy!.values.filter((v) => !section!.keys.includes(v))).toEqual([])
  })

  it('for-developers 壳：两张表的值全是真实 slug', () => {
    const source = readFileSync(resolve(HERE, '../playground/public/for-developers.html'), 'utf8')
    const section = parseTable(source, 'SECTION')
    const moved = parseTable(source, 'MOVED')

    expect(section).not.toBeNull()
    expect(moved).not.toBeNull()
    expect(section!.keys.length).toBeGreaterThanOrEqual(14)
    expect(section!.values.filter((v) => !KNOWN.has(v))).toEqual([])
    expect(moved!.values.filter((v) => !KNOWN.has(v))).toEqual([])
  })

  it('两个壳都真的是壳 —— 有 location.replace 且不再带正文', () => {
    for (const file of ['playground/public/onboarding.html', 'playground/public/for-developers.html']) {
      const source = readFileSync(resolve(HERE, '..', file), 'utf8')
      expect(source).toContain('location.replace(')
      // 正文搬走后壳应该很小；留 8KB 余量，真回填正文会立刻超
      expect(source.length).toBeLessThan(8000)
      // 正文特征类名一个都不该剩。⚠️ 必须连 `class="` 一起匹配：只写裸 `docs-section`
      // 会被壳注释里引用的本测试文件名 `docs-section-anchor.test.ts` 命中（我第一版就这么假阳过）。
      for (const cls of [
        'class="you"',
        'class="note"',
        'class="tscroll"',
        'class="docs-section"',
      ]) {
        expect(source).not.toContain(cls)
      }
    }
  })
})
