// tests/figma-payload-limit.test.ts
// -----------------------------------------------------------------------------
// [[INFRA-F103]] 修法 C + D 落地的单测。
//
// ⚠️ **它钉的是判别式与挂载形态，不是「运行期还会不会崩」**。后者要真调 Figma REST 拉 DS 库
// fileKey ⇒ 属本仓禁止动作（AGENTS 硬规则 #1 边界 + F103 entry §诚实边界）。⛔ 别把这里的绿
// 读成「大文件现在跑得完」—— 本轮删掉的是冗余膨胀 + 让失败可读，**上限没有被消除**。
// -----------------------------------------------------------------------------
import { describe, it, expect } from 'vitest'
import { readFileSync } from 'node:fs'
import { resolve } from 'node:path'
import { mkdtempSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import {
  isPayloadTooLargeError,
  payloadTooLargeMessage,
  payloadTooLargeError,
  readDumpFileHashFromHead,
  PAYLOAD_TOO_LARGE_CODE,
} from '../scripts/lib/figma-payload-limit.mjs'

const REPO_ROOT = resolve(__dirname, '..')
const read = (rel: string) => readFileSync(resolve(REPO_ROOT, rel), 'utf-8')

describe('isPayloadTooLargeError — 两条判据的并集', () => {
  it('认 V8 真抛的那个 RangeError', () => {
    // 真造一个越限：`repeat` 超上限时 V8 抛的就是 RangeError: Invalid string length
    let caught: unknown = null
    try {
      'x'.repeat(Number.MAX_SAFE_INTEGER)
    } catch (e) {
      caught = e
    }
    expect(caught).toBeInstanceOf(RangeError)
    expect(isPayloadTooLargeError(caught)).toBe(true)
  })

  it('instanceof 失效时靠 message 兜住（跨 realm / 被包装过的错误）', () => {
    // 这一条正是「只写 e instanceof RangeError」会漏掉的形态
    const crossRealm = { name: 'RangeError', message: 'Invalid string length' }
    expect(crossRealm instanceof RangeError).toBe(false)
    expect(isPayloadTooLargeError(crossRealm)).toBe(true)
  })

  it('阴性对照：别的解码失败不该被误判成越限', () => {
    expect(isPayloadTooLargeError(new SyntaxError('Unexpected token < in JSON at position 0'))).toBe(
      false,
    )
    expect(isPayloadTooLargeError(new TypeError('fetch failed'))).toBe(false)
    expect(isPayloadTooLargeError(null)).toBe(false)
    expect(isPayloadTooLargeError(undefined)).toBe(false)
  })
})

describe('payloadTooLargeMessage — 文案必须说清「不是凭据问题」+「上限仍在」', () => {
  const msg = payloadTooLargeMessage('/files/ABC', new RangeError('Invalid string length'), [
    '加 --node',
  ])

  it('点明请求已成功、崩在解码阶段（否则读者第一反应是去查 token）', () => {
    expect(msg).toContain('不是凭据')
    expect(msg).toContain('解码阶段')
  })

  it('⛔ 显式声明上限没被消除 —— 不许把本轮读成已修', () => {
    expect(msg).toContain('上限没有被消除')
  })

  it('带上调用面各自的缩小办法 + 请求路径', () => {
    expect(msg).toContain('/files/ABC')
    expect(msg).toContain('加 --node')
  })
})

describe('修法 C —— 冗余 geometry=paths 已从两处 URL 删净', () => {
  const src = read('scripts/audit-mockup-integrity.mjs')

  it('两条请求 URL 都不带 geometry 参数', () => {
    // ⚠️ 判据只看**模板字符串里的 URL**，不看注释 —— 注释里刻意留着 `geometry=paths` 字样
    // 解释「为什么不能加回来」，裸 grep 整个文件会把那些注释读成「缺陷仍在」。
    const urlLines = src
      .split('\n')
      .filter((l) => /figmaGet\(|`\/files\//.test(l))
      .filter((l) => !/^\s*(\*|\/\/)/.test(l))
    expect(urlLines.length).toBeGreaterThan(0) // 致败探针：一条都没抓到就是判据坏了，不是通过
    expect(urlLines.filter((l) => l.includes('geometry'))).toEqual([])
  })

  it('两条 URL 仍在（别把「删干净」做成把请求删了）', () => {
    expect(src).toContain('`/files/${FILE_KEY}/nodes?ids=${encodeURIComponent(NODE_ID)}`')
    expect(src).toContain('`/files/${FILE_KEY}`')
  })

  it('四条 probe 确实不消费任何 geometry 字段（修法 C 的前提）', () => {
    const body = src.replace(/^\s*(\*|\/\/).*$/gm, '') // 去注释行
    expect(body).not.toMatch(/\.(fillGeometry|strokeGeometry|vectorPaths|vectorNetwork)\b/)
  })
})

describe('修法 D —— 四条错误路径的退出码互不相同 + 调用方跟着改了', () => {
  const src = read('scripts/audit-mockup-integrity.mjs')

  it('响应体越限单独占 exit 3', () => {
    expect(src).toMatch(/isPayloadTooLargeError\(e\)[\s\S]{0,400}?\n\s*3,\n/)
  })

  it('Figma 非 2xx 占 exit 4，不再与 bad usage 撞 2', () => {
    expect(src).toMatch(/figma API \$\{res\.status\}[^\n]*, 4\)/)
  })

  it('bad usage 仍是 2（缺 --file 走 die 默认码）', () => {
    expect(src).toMatch(/function die\(msg, code = 2\)/)
  })

  it('conformance 的 ERROR 桶已放宽到 code >= 2（否则 3/4 会静默漏出统计）', () => {
    const conf = read('scripts/audit-mockup-conformance.mjs')
    expect(conf).toMatch(/const errored = results\.filter\(r => r\.code >= 2\)/)
  })
})

describe('两个解码点共用同一份判别式（别再各写一套）', () => {
  it('崩溃点 ① 与 ② 都 import 共用 lib', () => {
    expect(read('scripts/audit-mockup-integrity.mjs')).toMatch(
      /from '\.\/lib\/figma-payload-limit\.mjs'/,
    )
    expect(read('figma-sync/api.mjs')).toMatch(
      /from '\.\.\/scripts\/lib\/figma-payload-limit\.mjs'/,
    )
  })

  it('⛔ 崩溃点 ② 改的是模块私有 get()，不是 getFile（后者只是一行委托）', () => {
    const api = read('figma-sync/api.mjs')
    expect(api).toMatch(/async function get\(path\)[\s\S]*?isPayloadTooLargeError/)
    expect(api).toMatch(/export async function getFile\(fileKey = FILE_KEY\) \{\n\s*return get\(/)
  })
})

// ══════════════════════════════════════════════════════════════════════════════
// 以下 2026-08-20 随修法 A 追加
// ══════════════════════════════════════════════════════════════════════════════

describe('payloadTooLargeError —— 让上层不必匹配中文文案', () => {
  it('带 code，且包装后 instanceof RangeError 已失效（这就是它存在的理由）', () => {
    const wrapped = payloadTooLargeError('/files/K', new RangeError('Invalid string length'), ['x'])
    expect(wrapped).not.toBeInstanceOf(RangeError)
    expect(wrapped.code).toBe(PAYLOAD_TOO_LARGE_CODE)
    expect(isPayloadTooLargeError(wrapped)).toBe(true)
  })

  it('保留 cause，不丢原始现场', () => {
    const orig = new RangeError('Invalid string length')
    expect(payloadTooLargeError('/p', orig, []).cause).toBe(orig)
  })

  it('致败探针：message 换成完全无关的文案，识别仍成立 ⇒ 靠的是 code 不是文案', () => {
    const e: any = new Error('完全无关的一句话')
    e.code = PAYLOAD_TOO_LARGE_CODE
    expect(isPayloadTooLargeError(e)).toBe(true)
    // 阴性对照：去掉 code 就认不出来 ⇒ 上一条确实是 code 起的作用
    delete e.code
    expect(isPayloadTooLargeError(e)).toBe(false)
  })

  it('⛔ api.mjs 的 get() 必须用 payloadTooLargeError 而不是裸 new Error', () => {
    const api = read('figma-sync/api.mjs')
    expect(api).toMatch(/throw payloadTooLargeError\(path, e, \[/)
    // 且 getFileChunked 的回退判据不许绑在文案上
    expect(api).toMatch(/if \(!isPayloadTooLargeError\(e\)\) throw e/)
    expect(api).not.toMatch(/test\(String\(e\?\.message\)\)/)
  })
})

describe('readDumpFileHashFromHead —— 读 4 KB 取代读回 402 MB', () => {
  const dir = mkdtempSync(join(tmpdir(), 'f103-'))
  const H = 'a'.repeat(64)

  it('新格式 dump 读得出 fileSha256', () => {
    const p = join(dir, 'new.json')
    writeFileSync(
      p,
      `{"_meta":{"extractedAt":"x","source":"figma-api","fileKey":"K","fileSha256":"${H}"},"file":{"document":{}}}\n`,
    )
    expect(readDumpFileHashFromHead(p)).toBe(H)
  })

  it('旧 pretty 格式（无 fileSha256）→ null ⇒ 当作「变了」，是安全方向', () => {
    const p = join(dir, 'old.json')
    writeFileSync(p, '{\n  "_meta": {\n    "fileKey": "K"\n  },\n  "file": {}\n}\n')
    expect(readDumpFileHashFromHead(p)).toBe(null)
  })

  it('文件不存在 → null（不抛）', () => {
    expect(readDumpFileHashFromHead(join(dir, 'nope.json'))).toBe(null)
  })

  it('哈希落在读取窗口之外 → null（⛔ 不是静默读到一个错值）', () => {
    const p = join(dir, 'far.json')
    writeFileSync(p, `{"_pad":"${'P'.repeat(5000)}","_meta":{"fileSha256":"${H}"},"file":{}}\n`)
    expect(readDumpFileHashFromHead(p)).toBe(null)
    // 阴性对照：窗口开大就读得到 ⇒ 上一条不是因为正则写错
    expect(readDumpFileHashFromHead(p, 16384)).toBe(H)
  })

  it('只认 64 位十六进制，别的形状不当哈希用', () => {
    const p = join(dir, 'bad.json')
    writeFileSync(p, '{"_meta":{"fileSha256":"not-a-hash"},"file":{}}\n')
    expect(readDumpFileHashFromHead(p)).toBe(null)
  })
})

describe('第二道限 —— sync-mockup-data 全流程只序列化一次', () => {
  const src = read('figma-sync/sync-mockup-data.mjs')
  const code = src.replace(/^\s*(\*|\/\/).*$/gm, '') // 去注释行，否则数到注释里的说明

  it('⛔ 没有 `null, 2`（实测那是 4.09× 膨胀、把管线顶到上限 75%）', () => {
    expect(code).not.toMatch(/JSON\.stringify\([^)]*null,\s*2\)/)
  })

  it('JSON.stringify(file) 只出现一次', () => {
    expect(code.match(/JSON\.stringify\(file\)/g) ?? []).toHaveLength(1)
  })

  it('原来那三处大对象操作全部消失（readFileSync 整读 / structuredClone / .length 量体积）', () => {
    expect(code).not.toMatch(/JSON\.parse\(readFileSync/)
    expect(code).not.toMatch(/structuredClone/)
    expect(code).not.toMatch(/JSON\.stringify\([^)]*\)\.length/)
    expect(code).toMatch(/statSync\(outPath\)\.size/) // 体积改从磁盘读
  })

  it('走 getFileChunked（= 越限有旁路），且 missingPages 非空时 exit 1 不静默写', () => {
    expect(code).toMatch(/getFileChunked\(fileKey/)
    expect(code).toMatch(/if \(missingPages\.length\)/)
    expect(code).toMatch(/process\.exit\(1\)/)
  })

  it('⛔ `_meta` 必须仍是第一个 key —— 有个跨文件耦合靠这个（下面那条测它）', () => {
    expect(code).toMatch(/writeFileSync\(outPath, `\{"_meta":/)
  })
})

describe('跨文件耦合：compact 格式不能打破「只读头部 4 KB」的那个读者', () => {
  // `audit-mockup-conformance.mjs` 的头注释逐字写着它依赖「`_meta` 是 sync-mockup-data 写的
  // 第一个 key」，并只读头部 4 KB 取 `_meta.extractedAt`（因为整份实测 402 MB，禁止 JSON.parse）。
  // 改 dump 的序列化格式**必须**在这里被机械核一次 —— 不然「去掉 null,2」会静默打掉那条链。
  it('新 compact 头部仍能被 extractedAtFromHead 解出 extractedAt', async () => {
    const { extractedAtFromHead } = await import('../scripts/audit-mockup-conformance.mjs')
    const compactHead = '{"_meta":{"extractedAt":"2026-08-20T01:02:03.000Z","source":"figma-api","fileKey":"K","fileSha256":"' + 'a'.repeat(64) + '"},"file":{"document"'
    expect(extractedAtFromHead(compactHead)).toBe('2026-08-20T01:02:03.000Z')
  })

  it('阴性对照：旧 pretty 头部也仍然解得出（⇒ 这条判据不是只对新格式成立）', async () => {
    const { extractedAtFromHead } = await import('../scripts/audit-mockup-conformance.mjs')
    expect(extractedAtFromHead('{\n  "_meta": {\n    "extractedAt": "2026-07-17T00:00:00.000Z"')).toBe(
      '2026-07-17T00:00:00.000Z',
    )
  })

  it('致败探针：头部里没有 extractedAt 时必须返回 null，⛔ 不猜', async () => {
    const { extractedAtFromHead } = await import('../scripts/audit-mockup-conformance.mjs')
    expect(extractedAtFromHead('{"_meta":{"source":"figma-api"}}')).toBe(null)
  })
})
