// tests/audit-mockup-jira-hyperlink.test.ts
//
// §M23.8 —— `Jira requirement` 实例内的 issue-key 必带指向真实 Jira URL 的 hyperlink。
// 口径与两侧读数的唯一真源 =
//   ai-ds-lab/docs/2026-09-08-q1-b2-batch1-preregistration.md
//
// 🔴 **为什么全部用合成节点树，⛔ 不喂真 payload**（2026-09-08 亲验，⛔ 不是省事）：
//   `.gitignore:29` 逐字排掉 `figma-data/mockup/`，`git ls-files figma-data/mockup/` **为空**
//   ⇒ 那四份 payload 快照（0.5 MB + 3 份 340–390 MB）**只存在于开发者主工作树**。
//   任何依赖它们的断言在别的 clone / CI 上都取不到文件 ⇒ 要么红、要么被 skip 掉变成零信息。
//   ⇒ 本文件用**照真形态合成**的节点树（形态逐项取自本轮那次真 payload 测量，见下），
//     真 payload 上的分母只作为**一次本机读数**登记在判据头注释与预注册里，⛔ 不由闸守。
//
// ⚠️ **这一条推翻了预注册 P4 自己写的判据**（如实登记）：P4 原写「真 fixture 上 `Issue key`
//   节点数必须 ≥ 1」当分母地板。测到 `.gitignore` 那一行之后它**不可实现** ——
//   ⛔ 修的是判据不是事实：分母地板改为「合成树上判定对象识别不到就红」，
//   它守的是**判据的识别逻辑**，⛔ 守不到「真交付里还有没有 Jira annotation」。
//
// ── 合成形态的出处（都是本轮那次真 payload 测量的现取值）──────────────────
//   · issue-key 节点名逐字 `Issue key`，`characters` 逐字就是 ticket ID（如 `V4-2335`）
//   · 它的链**全部**走 `node.style.hyperlink = { type:'URL', url:'https://…/browse/<ID>' }`
//     —— 四份 payload 合计 109 个节点，走 `styleOverrideTable` 的 **0 个**
//   · 祖先是名字命中 `jira|requirement` 的 INSTANCE，id 形态 `I<num>:<num>;<num>:<num>`
import { describe, expect, it } from 'vitest'
import {
  classifyJiraHyperlink,
  collectIssueKeyNodes,
  linkCovering,
  hyperlinkUrl,
  TICKET_RE,
  JIRA_BROWSE_RE,
  ISSUE_KEY_NAME_RE,
} from '../scripts/audit-mockup-jira-hyperlink.mjs'

type AnyNode = Record<string, any>

const BROWSE = (id: string) => `https://tvunetworks.atlassian.net/browse/${id}`

/** 照真形态造一个 issue-key TEXT 节点。 */
function issueKey(opts: {
  id?: string
  chars?: string
  styleUrl?: string | null
  /** Plugin API 的键名（Acceptance 里写的就是这个）—— 用来钉住「两个键都读」 */
  stylePluginValue?: string | null
  /** 子串链：[start, end) 上挂 override hyperlink */
  override?: { start: number; end: number; url: string } | null
  name?: string
}): AnyNode {
  const chars = opts.chars ?? 'V4-2335'
  const node: AnyNode = {
    id: opts.id ?? 'I9306:10;1140:64',
    type: 'TEXT',
    name: opts.name ?? 'Issue key',
    characters: chars,
  }
  if (opts.styleUrl) node.style = { hyperlink: { type: 'URL', url: opts.styleUrl } }
  else if (opts.stylePluginValue) node.style = { hyperlink: { type: 'URL', value: opts.stylePluginValue } }
  if (opts.override) {
    node.characterStyleOverrides = Array.from({ length: chars.length }, (_, i) =>
      i >= opts.override!.start && i < opts.override!.end ? 7 : 0,
    )
    node.styleOverrideTable = { 7: { hyperlink: { type: 'URL', url: opts.override.url } } }
  }
  return node
}

/** 把节点包进一个 `Jira requirement` INSTANCE 再包进 document。 */
function docWithJira(...texts: AnyNode[]): AnyNode {
  return {
    id: '0:0',
    type: 'DOCUMENT',
    name: 'Document',
    children: [
      {
        id: '1:1',
        type: 'CANVAS',
        name: 'Page 1',
        children: [
          {
            id: 'I9306:10',
            type: 'INSTANCE',
            name: 'Jira requirement',
            children: [{ id: '9306:9', type: 'TEXT', name: 'Requirement Name', characters: 'Test Signal' }, ...texts],
          },
        ],
      },
    ],
  }
}

// ---------------------------------------------------------------------------
// 必须红 —— 三个 probe 各一条（P5）
// ---------------------------------------------------------------------------
describe('§M23.8 —— 必须红', () => {
  it('M23.8-A：issue-key 完全没有 hyperlink ⇒ 1 条,点名 nodeId 与 ticket', () => {
    const { violations, scanned } = classifyJiraHyperlink(docWithJira(issueKey({ styleUrl: null })))
    expect(scanned).toBe(1)
    expect(violations).toHaveLength(1)
    expect(violations[0].probe).toBe('M23.8-A')
    expect(violations[0].nodeId).toBe('I9306:10;1140:64')
    expect(violations[0].ticket).toBe('V4-2335')
  })

  it('M23.8-B：有链但不是 Jira browse 地址 ⇒ 命中,并印出现有 URL', () => {
    const { violations } = classifyJiraHyperlink(
      docWithJira(issueKey({ styleUrl: 'https://example.com/tickets/V4-2335' })),
    )
    expect(violations).toHaveLength(1)
    expect(violations[0].probe).toBe('M23.8-B')
    expect(violations[0].why).toContain('https://example.com/tickets/V4-2335')
  })

  // 🔴 这一条对应 §M23.8 自陈的工作流「clone 一个现成 instance,改 title text +
  //    issue-key text + setRangeHyperlink」—— 漏掉第三步就长这样。
  it('M23.8-C：文本 ID 与链接里的 ID 不一致 ⇒ 命中,报文印出两个 ID', () => {
    const { violations } = classifyJiraHyperlink(
      docWithJira(issueKey({ chars: 'V4-2999', styleUrl: BROWSE('V4-2335') })),
    )
    expect(violations).toHaveLength(1)
    expect(violations[0].probe).toBe('M23.8-C')
    expect(violations[0].why).toContain('V4-2999')
    expect(violations[0].why).toContain('V4-2335')
  })

  it('三个 probe 在同一份文档里各自命中,⛔ 不互相吞掉', () => {
    const { violations, scanned } = classifyJiraHyperlink(
      docWithJira(
        issueKey({ id: 'I1;1', chars: 'FB-1', styleUrl: null }),
        issueKey({ id: 'I1;2', chars: 'FB-2', styleUrl: 'https://example.com/FB-2' }),
        issueKey({ id: 'I1;3', chars: 'FB-3', styleUrl: BROWSE('FB-4') }),
      ),
    )
    expect(scanned).toBe(3)
    expect(violations.map((v: any) => v.probe).sort()).toEqual(['M23.8-A', 'M23.8-B', 'M23.8-C'])
  })
})

// ---------------------------------------------------------------------------
// 不许误报 —— 每条对应一个已实测的形态（P3）
// ---------------------------------------------------------------------------
describe('§M23.8 —— 不许误报', () => {
  // 🔴 **这是本判据最要紧的一条正向对照。** 真 payload 里 109/109 个 issue-key 的链
  //    都走 `node.style.hyperlink`，走 override 表的 0 个。
  //    ⇒ 判据若只读 override 表，会把这 109 条合规全判成违例（本轮真踩过一次）。
  //    ⛔ 别把 `linkCovering` 里读 `style.hyperlink` 那一支删掉。
  it('整串链落 `node.style.hyperlink`（真 payload 里 109/109 的形态）⇒ 0 条', () => {
    const { violations, scanned } = classifyJiraHyperlink(
      docWithJira(issueKey({ chars: 'V4-2335', styleUrl: BROWSE('V4-2335') })),
    )
    expect(scanned).toBe(1)
    expect(violations).toEqual([])
  })

  it('子串链落 `styleOverrideTable` ⇒ 0 条（另一条来源也要能识别）', () => {
    const node = issueKey({ chars: 'V4-2335', styleUrl: null, override: { start: 0, end: 7, url: BROWSE('V4-2335') } })
    const { violations, scanned } = classifyJiraHyperlink(docWithJira(node))
    expect(scanned).toBe(1)
    expect(violations).toEqual([])
  })

  // 🔴 §M23.8 的 Acceptance 逐字写 `{ type: 'URL', value: '<jira url>' }` —— 那是
  //    **Plugin API** 的键名，REST payload 里是 `url`。⛔ 两个都要读。
  it('Plugin API 形状的 `value` 键也要认（Acceptance 里写的就是它）⇒ 0 条', () => {
    const { violations } = classifyJiraHyperlink(
      docWithJira(issueKey({ chars: 'V4-2335', stylePluginValue: BROWSE('V4-2335') })),
    )
    expect(violations).toEqual([])
  })

  // §M23.8 射程逐字是「加 `Jira requirement` component instance 时」⇒ 实例外的不判。
  it('名字像 issue-key 但不在 `Jira requirement` 实例内 ⇒ 不判,只计 outsideCount', () => {
    const doc = {
      id: '0:0',
      type: 'DOCUMENT',
      children: [{ id: '1:1', type: 'CANVAS', children: [issueKey({ id: '5:5', styleUrl: null })] }],
    }
    const r = classifyJiraHyperlink(doc as any)
    expect(r.violations).toEqual([])
    expect(r.scanned).toBe(0)
    expect(r.outsideCount).toBe(1)
  })

  it('issue-key 节点里没有 ticket 形态（占位 / 空）⇒ 不算判过、也不报违反', () => {
    const r = classifyJiraHyperlink(docWithJira(issueKey({ chars: 'TBD', styleUrl: null })))
    expect(r.scanned).toBe(0)
    expect(r.violations).toEqual([])
  })

  it('文档里根本没有 Jira annotation ⇒ scanned 0（⛔ 这不可读作「0 违反」）', () => {
    const doc = { id: '0:0', type: 'DOCUMENT', children: [{ id: '1:1', type: 'CANVAS', children: [] }] }
    const r = classifyJiraHyperlink(doc as any)
    expect(r.scanned).toBe(0)
    expect(r.jiraInstanceCount).toBe(0)
    expect(r.violations).toEqual([])
  })
})

// ---------------------------------------------------------------------------
// 判定对象的识别 —— 分母地板（合成层。⛔ 守不到真交付,见文件头）
// ---------------------------------------------------------------------------
describe('判定对象识别 —— 写坏了就红', () => {
  it('照真形态的树上必须识别到 issue-key 节点与 Jira 实例（识别写坏 ⇒ 分母塌 0 ⇒ 本条红）', () => {
    const r = collectIssueKeyNodes(docWithJira(issueKey({})) as any)
    expect(r.inside.length, '判定对象识别不到 ⇒ 判据分母塌成 0 ⇒ 它已恒绿').toBe(1)
    expect(r.jiraInstanceCount).toBe(1)
    expect(r.outsideCount).toBe(0)
  })

  it('`linkCovering` 对整串链必须报 via=style（删掉那一支 ⇒ 本条红）', () => {
    const node = issueKey({ chars: 'V4-2335', styleUrl: BROWSE('V4-2335') })
    expect(linkCovering(node, 0, 7)).toEqual({ url: BROWSE('V4-2335'), via: 'style' })
  })

  it('`linkCovering` 对子串链必须报 via=override', () => {
    const node = issueKey({ chars: 'V4-2335', styleUrl: null, override: { start: 0, end: 7, url: BROWSE('V4-2335') } })
    expect(linkCovering(node, 0, 7)).toEqual({ url: BROWSE('V4-2335'), via: 'override' })
  })

  it('`linkCovering` ⛔ 不许把「链挂在别的字符上」当成覆盖', () => {
    const node = issueKey({ chars: 'see V4-2335', styleUrl: null, override: { start: 0, end: 3, url: BROWSE('V4-2335') } })
    expect(linkCovering(node, 4, 11)).toBeNull()
  })

  it('`hyperlinkUrl` 两个键都读、`url` 优先', () => {
    expect(hyperlinkUrl({ type: 'URL', url: 'a' })).toBe('a')
    expect(hyperlinkUrl({ type: 'URL', value: 'b' })).toBe('b')
    expect(hyperlinkUrl({ type: 'URL', url: 'a', value: 'b' })).toBe('a')
    expect(hyperlinkUrl(null)).toBeNull()
    expect(hyperlinkUrl({ type: 'URL' })).toBeNull()
  })
})

// ---------------------------------------------------------------------------
// 形态判据本身 —— 闭集前缀那个坑（F3）
// ---------------------------------------------------------------------------
describe('ticket 形态 ⛔ 不能用闭集前缀', () => {
  // 🔴 裁定表原本提的形态是闭集 `FB-\d+ / V4-\d+ / SEC-\d+`。实测:一份 payload 的 89 个
  //    issue-key 节点里 **21 个（24%）**的前缀不在那个闭集里。下面这些前缀**都是真 payload
  //    里现取到的**，⛔ 不是编的。
  for (const id of ['FB-9398', 'V4-2335', 'SEC-12', 'BM-1047', 'THS-191', 'MC-44', 'TPC-657']) {
    it(`真 payload 里出现过的 \`${id}\` 必须被识别`, () => {
      expect(TICKET_RE.test(id)).toBe(true)
      expect(id.match(TICKET_RE)?.[0]).toBe(id)
    })
  }

  it('⛔ 不许把普通带连字符的词当成 ticket', () => {
    expect(TICKET_RE.test('Test-Signal')).toBe(false)
    expect(TICKET_RE.test('lower-1')).toBe(false)
  })

  it('JIRA_BROWSE_RE 认实现例那个 URL,并抽出其中的 ID', () => {
    expect(BROWSE('FB-9398').match(JIRA_BROWSE_RE)?.[1]).toBe('FB-9398')
  })

  // ⛔ 主机名必须真的以 atlassian.net 结尾 —— 路径里带 `atlassian.net` 的钓鱼型 URL 不算。
  it('JIRA_BROWSE_RE ⛔ 不认把 atlassian.net 塞进路径的 URL', () => {
    expect(JIRA_BROWSE_RE.test('https://evil.example.com/atlassian.net/browse/FB-1')).toBe(false)
  })

  it('ISSUE_KEY_NAME_RE 认真 payload 里那个名字,且大小写/空格宽容', () => {
    expect(ISSUE_KEY_NAME_RE.test('Issue key')).toBe(true)
    expect(ISSUE_KEY_NAME_RE.test('issuekey')).toBe(true)
    expect(ISSUE_KEY_NAME_RE.test('Requirement Name')).toBe(false)
  })
})
