// 产品级 pattern 索引的结构提取自测（纯函数，不打网络）。
//
// 真源 = scripts/lib/product-pattern-index.mjs，唯一消费方 = scripts/ds-health-scan-mockups.mjs。
// 本文件钉住三件容易悄悄坏、坏了又看不出来的事：
//   ① 变体上卷 —— 一个 8 变体的卡片必须记 1 个 pattern，不是 8 个（否则「产品有多少 pattern」直接虚高 8 倍）
//   ② 热度只数 file-local —— remote 实例混进来会把「别人库的用量」记成「本产品 pattern 的热度」
//   ③ 名称质量是启发式且**只影响可查性标注**，⛔ 不影响 pattern 是否入表

import { describe, expect, it } from 'vitest'
// @ts-expect-error — plain ESM JS lib without .d.ts
import { walkStructure, countLocalInstances, assemblePatternIndex, findOrphanLocalInstances, nameQuality, patternUrl } from '../scripts/lib/product-pattern-index.mjs'

// 一个最小但覆盖全部四种形态的文件：
//   页 A：CardSet（COMPONENT_SET，2 变体） + StatusRow（独立 COMPONENT） + 两个实例
//   页 B：Group 61（默认名 COMPONENT，零实例） + 一个指向 remote 组件的实例
const document = {
  type: 'DOCUMENT',
  id: '0:0',
  children: [
    {
      type: 'CANVAS',
      id: 'p:A',
      name: 'Page A',
      children: [
        {
          type: 'COMPONENT_SET',
          id: 's:1',
          name: 'Device Status Card',
          children: [
            { type: 'COMPONENT', id: 'c:1a', name: 'State=Online' },
            { type: 'COMPONENT', id: 'c:1b', name: 'State=Offline' },
          ],
        },
        { type: 'COMPONENT', id: 'c:2', name: 'Alert Row' },
        {
          type: 'FRAME',
          id: 'f:1',
          children: [
            { type: 'INSTANCE', id: 'i:1', name: 'card', componentId: 'c:1a' },
            { type: 'INSTANCE', id: 'i:2', name: 'card', componentId: 'c:1b' },
            { type: 'INSTANCE', id: 'i:3', name: 'row', componentId: 'c:2' },
          ],
        },
      ],
    },
    {
      type: 'CANVAS',
      id: 'p:B',
      name: 'Page B',
      children: [
        { type: 'COMPONENT', id: 'c:3', name: 'Group 61' },
        { type: 'INSTANCE', id: 'i:4', name: 'btn', componentId: 'c-remote' },
      ],
    },
  ],
}

const componentsMap = {
  'c:1a': { key: 'K1A', remote: false, name: 'Device Status Card' },
  'c:1b': { key: 'K1B', remote: false, name: 'Device Status Card' },
  'c:2': { key: 'K2', remote: false, name: 'Alert Row', description: '告警行' },
  'c-remote': { key: 'K-DS', remote: true, name: 'Button' },
}

const build = () => {
  const structure = walkStructure(document)
  const instances: any[] = []
  const walk = (n: any) => { if (n?.type === 'INSTANCE') instances.push(n); for (const c of n?.children || []) walk(c) }
  walk(document)
  return assemblePatternIndex(structure, countLocalInstances(instances, componentsMap), componentsMap)
}

describe('产品 pattern 索引 · 结构提取', () => {
  it('收 COMPONENT + COMPONENT_SET，页归属正确', () => {
    const { patterns, pageOf } = walkStructure(document)
    expect(patterns.map((p: any) => p.nodeId).sort()).toEqual(['c:1a', 'c:1b', 'c:2', 'c:3', 's:1'])
    expect(pageOf.get('s:1')).toEqual({ id: 'p:A', name: 'Page A' })
    expect(pageOf.get('i:4')).toEqual({ id: 'p:B', name: 'Page B' })
    // CANVAS 自己不是 pattern
    expect(patterns.some((p: any) => p.type === 'CANVAS')).toBe(false)
  })

  it('① 变体上卷：8 变体的卡片记 1 个 pattern，不是 8 个', () => {
    const idx = build()
    expect(idx.map((p: any) => p.nodeId)).toEqual(['s:1', 'c:2', 'c:3'])
    const set = idx.find((p: any) => p.nodeId === 's:1')
    expect(set).toMatchObject({ type: 'COMPONENT_SET', variants: 2, instanceCount: 2, page: 'Page A' })
    // 变体自己 ⛔ 不单独出现在清单里
    expect(idx.some((p: any) => p.nodeId === 'c:1a')).toBe(false)
  })

  it('② 热度只数 file-local —— remote 实例不进任何 pattern 的计数', () => {
    const counts = countLocalInstances(
      [
        { id: 'i:3', name: 'row', componentId: 'c:2' },
        { id: 'i:4', name: 'btn', componentId: 'c-remote' },
        { id: 'i:5', name: 'x', componentId: 'c-missing' },
      ] as any,
      componentsMap,
    )
    expect(counts.get('c:2')).toBe(1)
    expect(counts.has('c-remote')).toBe(false) // remote = DIM-U12 的事，不是本产品 pattern 热度
    expect(counts.has('c-missing')).toBe(false) // 解析不到 ⇒ 跳过，不猜
  })

  it('③ 零实例的 pattern 照样入表（「有什么」≠「用了几次」）', () => {
    const idx = build()
    const orphan = idx.find((p: any) => p.nodeId === 'c:3')
    expect(orphan).toMatchObject({ instanceCount: 0, nameQuality: 'generic', page: 'Page B' })
  })

  it('名称质量：默认名 generic / 有语义 named（含近似边界）', () => {
    expect(['Group 61', 'Frame', 'Rectangle 12', 'Untitled', '', '  '].map(nameQuality))
      .toEqual(['generic', 'generic', 'generic', 'generic', 'generic', 'generic'])
    // ⚠️ 阴性对照：名字里**含**默认词但不是纯默认名的，必须判 named —— 否则会把
    //    「Group Selector」这种真 pattern 误标成没语义
    expect(['Device Status Card', 'Group Selector', 'Frame Rate Picker', 'Alert Row'].map(nameQuality))
      .toEqual(['named', 'named', 'named', 'named'])
  })

  it('按热度降序，深链把 `:` 换成 `-`（Figma URL 形态）', () => {
    const idx = build()
    expect(idx.map((p: any) => p.instanceCount)).toEqual([2, 1, 0])
    expect(patternUrl('FILEKEY', '1:23')).toBe('https://www.figma.com/design/FILEKEY/?node-id=1-23')
  })

  // 2026-08-14 首版清理清单实测踩过：只 replace 第一个 `:` 会把嵌套实例的深链变成打不开的链接。
  it('⛔ 深链把**所有** `:` 换成 `-`（嵌套实例 id 有多段）', () => {
    expect(patternUrl('FK', 'I6602:2277;892:7085;892:6841'))
      .toBe('https://www.figma.com/design/FK/?node-id=I6602-2277%3B892-7085%3B892-6841')
    // ⚠️ 只看 node-id 参数：URL 的 `https://` 自己就含 `:`（首版断言在这里写错过）
    const param = (u: string) => u.split('node-id=')[1]
    expect(param(patternUrl('FK', 'I6602:2277;892:7085'))).not.toContain(':')
    expect(param(patternUrl('FK', '1:23'))).toBe('1-23')
  })

  // 2026-08-14 LCD 首测实测：1604 file-local 实例里有 14 个归不到任何 pattern，
  // 追下去是 4 个 master 已被删/移出文件的孤儿。它不是 bug 而是产品事实，但**必须被解释**——
  // 没解释的差就是假账。这两条钉住「差能被解释」和「解释完不剩」。
  describe('孤儿 master（master 已不在文件里）', () => {
    const orphanDoc = {
      type: 'DOCUMENT',
      children: [{
        type: 'CANVAS', id: 'p:1', name: 'P', children: [
          { type: 'COMPONENT', id: 'c:live', name: 'Live One' },
          { type: 'FRAME', children: [
            { type: 'INSTANCE', id: 'i:1', componentId: 'c:live', name: 'a' },
            { type: 'INSTANCE', id: 'i:2', componentId: 'c:gone', name: 'b' },
            { type: 'INSTANCE', id: 'i:3', componentId: 'c:gone', name: 'c' },
          ] },
        ],
      }],
    }
    const map = {
      'c:live': { key: 'K1', remote: false, name: 'Live One' },
      'c:gone': { key: 'K2', remote: false, name: 'Status=Unmute', componentSetId: 's:dead' },
    }
    const insts = [
      { id: 'i:1', componentId: 'c:live', name: 'a' },
      { id: 'i:2', componentId: 'c:gone', name: 'b' },
      { id: 'i:3', componentId: 'c:gone', name: 'c' },
    ] as any

    it('找出孤儿并带上它原本所属的 set', () => {
      const orphans = findOrphanLocalInstances(insts, map, walkStructure(orphanDoc))
      expect(orphans).toEqual([{ componentId: 'c:gone', name: 'Status=Unmute', componentSetId: 's:dead', instances: 2 }])
    })

    it('⛔ 对账恒等式闭合：file-local = 归到 pattern + 孤儿 + 0 未解释', () => {
      const structure = walkStructure(orphanDoc)
      const idx = assemblePatternIndex(structure, countLocalInstances(insts, map), map)
      const attributed = idx.reduce((a: number, p: any) => a + p.instanceCount, 0)
      const orphanInstances = findOrphanLocalInstances(insts, map, structure).reduce((a: number, o: any) => a + o.instances, 0)
      const localTotal = insts.filter((i: any) => map[i.componentId] && map[i.componentId].remote !== true).length
      expect(localTotal - attributed - orphanInstances).toBe(0)
    })

    it('remote 实例 ⛔ 不算孤儿（那是 DIM-U12 的事）', () => {
      const withRemote = [...insts, { id: 'i:9', componentId: 'c:ds', name: 'btn' }] as any
      const m2 = { ...map, 'c:ds': { key: 'K-DS', remote: true, name: 'Button' } }
      expect(findOrphanLocalInstances(withRemote, m2, walkStructure(orphanDoc)).map((o: any) => o.componentId)).toEqual(['c:gone'])
    })
  })

  it('传子树（无 CANVAS 层）不崩，page 记 null', () => {
    const sub = { type: 'FRAME', id: 'f:9', children: [{ type: 'COMPONENT', id: 'c:9', name: 'Sub' }] }
    const { patterns } = walkStructure(sub)
    expect(patterns).toEqual([{ nodeId: 'c:9', name: 'Sub', type: 'COMPONENT', page: null, parentSetId: null }])
  })
})
