#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""天隆集团趋势看板 —— 验收脚本（真实输出，逐项断言）。

  env -u PYTHONPATH /usr/bin/python3 脚本/verify_tianlong_trend.py

检查项：
  A 零外部请求 / 零 console error / 零 pageerror
  B 四块均渲染（#blk1..#blk4，各自的 svg / 卡片 / 条形 / 列 计数）
  C sparkline 数量 == 货号数（15），且每格 data-25/data-26 与权威数据一致
  D 页面显示数字 由内嵌 DATA 复算，并与权威值独立复算一致（≥3 个）
  E SVG 几何：内容包围盒落在 viewBox 内；整页无横向溢出
  F 截图 2 张（整页 + 小图阵局部）
  G 工具/index.html、工具/app.html 的 mtime 未变
  H 业务注解：三条关键词 / 角标 ≥ 4 / 「业务注解与数据印证」块 / 结论归因句
"""
import json
import os
import re
import sys

HERE = os.path.dirname(os.path.abspath(__file__))
ROOT = os.path.dirname(HERE)
HTML = os.path.join(ROOT, '工具', '天隆_2025vs2026_趋势看板.html')
SHOT = '/Users/liuxinyuan/.hermes/profiles/zone-b/cache/scratch/tianlong_trend_verify'
EXPECT = {os.path.join(ROOT, '工具', 'index.html'): 1791615019,
          os.path.join(ROOT, '工具', 'app.html'): 1791615019}

# ── 权威数据（与生成器同源，此处独立再写一遍做交叉复算）────────
A_T25 = [350800, 17900, 1035647, 1189300]
A_T26 = [572500, 236710, 418850, 0]
A_SKUS = {
    'M4211-3': (165000, 423300), 'M5134-TL-1': (181000, 175000),
    'M5134-TL2-1': (182000, 150000), 'MD2071-2': (209200, 110000),
    'M5244-ZJ4-TP': (544800, 94500), 'MD2071-2-NA': (27600, 60000),
    'M5244-T3-DB': (0, 47100), 'MD2104-003006-DB': (0, 27760),
    'PM202': (51600, 25200), 'MD2071-2-1': (71500, 22000),
    'M5244-043801-1': (0, 19200), 'FM0301-042902': (0, 18000),
    'M5244-T1-DB-1': (9000, 15000), 'M5244-ZJ4-TP-1': (0, 12000),
    'MD2104-003003-DB': (0, 8400),
}
FAIL = []
OKN = [0]


def chk(name, cond, detail=''):
    OKN[0] += 1
    print('%s %s%s' % ('✅' if cond else '❌', name, ('  ｜ ' + detail) if detail else ''))
    if not cond:
        FAIL.append(name)


from playwright.sync_api import sync_playwright   # noqa: E402

os.environ.setdefault('PYTHONPATH', '')

for p, m in EXPECT.items():
    chk('G/前置 mtime 基线可读 %s' % os.path.basename(p), os.path.exists(p))

# ── 源表交叉核对（独立第三方口径：销售分析/天隆集团_2025vs2026_货号分季度同比.xlsx）
import pandas as pd   # noqa: E402
SRC = os.path.join(ROOT, '销售分析', '天隆集团_2025vs2026_货号分季度同比.xlsx')
_s = pd.read_excel(SRC, sheet_name='货号x季度x同比')
_sO = pd.read_excel(SRC, sheet_name='总览').set_index('指标')['值'].to_dict()
SRC_MAP, SRC_Q = {}, {}
for _, r in _s.iterrows():
    k = str(r['货号']).strip()
    SRC_MAP[k] = (int(r['25合计']), int(r['26合计']), int(r['同比Δ']))
    SRC_Q[k] = [int(r['25Q1']), int(r['25Q2']), int(r['25Q3']), int(r['25Q4']),
                int(r['26Q1']), int(r['26Q2']), int(r['26Q3']), int(r['26Q4'])]
print('   源表《货号x季度x同比》%d 行；总览：2025 %s / 2026 %s / 差额 %s / 同比 %s'
      % (len(SRC_MAP), _sO['2025 合计'], _sO['2026 合计'], _sO['同比差额'], _sO['同比%']))

ext_req, console_err, page_err = [], [], []

with sync_playwright() as p:
    b = p.chromium.launch(channel='chrome')
    pg = b.new_page(viewport={'width': 1440, 'height': 1000}, device_scale_factor=2)
    pg.on('request', lambda r: ext_req.append(r.url)
          if not re.match(r'^(file:|data:|blob:|about:)', r.url) else None)
    pg.on('console', lambda m: console_err.append(m.text) if m.type == 'error' else None)
    pg.on('pageerror', lambda e: page_err.append(str(e)))
    pg.goto('file://' + HTML)
    pg.wait_for_load_state('load')
    pg.wait_for_timeout(900)

    # ── A ──
    chk('A1 外部请求 == 0', len(ext_req) == 0, '命中 %d 条 %s' % (len(ext_req), ext_req[:3]))
    chk('A2 console error == 0', len(console_err) == 0, str(console_err[:3]))
    chk('A3 pageerror == 0', len(page_err) == 0, str(page_err[:3]))

    # ── E 几何 ──
    geo = pg.evaluate("""(() => {
      var out={overflow:document.documentElement.scrollWidth-document.documentElement.clientWidth,
               bad:[], n:0};
      [].slice.call(document.querySelectorAll('svg')).forEach(function(s){
        var vb=(s.getAttribute('viewBox')||'0 0 0 0').split(/\\s+/).map(Number);
        var bb=s.getBBox(); out.n++;
        var tol=1.5;
        if(bb.width<=0 || bb.height<=0) out.bad.push(['empty',s.getAttribute('class')]);
        else if(bb.x<vb[0]-tol || bb.y<vb[1]-tol ||
                bb.x+bb.width>vb[0]+vb[2]+tol || bb.y+bb.height>vb[1]+vb[3]+tol)
          out.bad.push([s.getAttribute('class'), [bb.x,bb.y,bb.width,bb.height], vb]);
      });
      return out;})()""")
    chk('E1 整页无横向溢出', geo['overflow'] == 0, 'overflow=%s px' % geo['overflow'])
    chk('E2 SVG 包围盒均在 viewBox 内（%d 个）' % geo['n'], len(geo['bad']) == 0,
        str(geo['bad'][:2]))

    # ── B 四块渲染 ──
    blocks = pg.evaluate("""(() => {
      function h(id){var e=document.getElementById(id);
        return e?Math.round(e.getBoundingClientRect().height):-1;}
      return {b1:h('blk1'),b2:h('blk2'),b3:h('blk3'),b4:h('blk4'),
        main:document.querySelectorAll('svg.main-line').length,
        sparks:document.querySelectorAll('svg.spark').length,
        bars:document.querySelectorAll('svg.bars rect[height="22"]').length,
        cols:document.querySelectorAll('.struct-cols .scol').length,
        chips:document.querySelectorAll('.struct-cols .chip').length,
        segs:document.querySelectorAll('.struct-bar .sb-seg').length,
        cards:document.querySelectorAll('#grid .card').length,
        hl:document.querySelectorAll('#grid .card.hl').length,
        svgText:document.querySelectorAll('svg text').length,
        titleInSvg:document.querySelectorAll('svg title').length,
        brInSvg:document.querySelectorAll('svg br').length,
        bInSvg:document.querySelectorAll('svg b').length};})()""")
    for k, v in blocks.items():
        print('      · %s = %s' % (k, v))
    chk('B1 四块均渲染（高度>200）',
        all(blocks['b%d' % i] > 200 for i in (1, 2, 3, 4)))
    chk('B2 季度双线图 1 张', blocks['main'] == 1)
    chk('B3 同比条形 rect == 15（每货号一条）', blocks['bars'] == 15, '%d' % blocks['bars'])
    chk('B4 结构变迁 3 列 / 24 chips / 3 段带',
        blocks['cols'] == 3 and blocks['chips'] == 24 and blocks['segs'] == 3,
        'cols=%d chips=%d segs=%d' % (blocks['cols'], blocks['chips'], blocks['segs']))
    chk('B5 SVG 内无 <title>/<br>/<b>（防静默吞图形）',
        blocks['titleInSvg'] == 0 and blocks['brInSvg'] == 0 and blocks['bInSvg'] == 0)
    chk('B6 SVG 文字节点 > 80（图形真在画）', blocks['svgText'] > 80,
        '%d 个' % blocks['svgText'])

    # ── C sparkline == 15 且每格数据与权威一致 ──
    chk('C1 sparkline 数量 == 货号数 15', blocks['sparks'] == 15, '%d 个' % blocks['sparks'])
    chk('C2 卡片数 == 15，高亮 2 张', blocks['cards'] == 15 and blocks['hl'] == 2,
        'cards=%d hl=%d' % (blocks['cards'], blocks['hl']))
    page_skus = pg.evaluate("""(() => {var o={};
      [].slice.call(document.querySelectorAll('#grid .card')).forEach(function(c){
        o[c.querySelector('.sku').textContent.trim()]=
          [parseInt(c.getAttribute('data-25')),parseInt(c.getAttribute('data-26'))];});
      return o;})()""")
    bad = {k: v for k, v in page_skus.items() if A_SKUS.get(k) != tuple(v)}
    chk('C3 15 格 data-25/data-26 与权威值逐格一致', not bad, str(bad))
    chk('C4 卡片顺序 == 按 2026 金额降序',
        [k for k, _ in sorted(page_skus.items(), key=lambda kv: -kv[1][1])] == list(page_skus.keys()))

    # ── D 由内嵌 DATA 复算 vs 页面显示 vs 权威值 ──
    rec = pg.evaluate("""(() => ({recalc:window.__recalc,
      dom25:document.getElementById('kpi-t25').textContent.trim(),
      dom26:document.getElementById('kpi-t26').textContent.trim(),
      domDiff:document.getElementById('kpi-diff').textContent.trim(),
      domYoy:document.getElementById('kpi-yoy').textContent.trim(),
      dataQ25:window.DATA.q25, dataQ26:window.DATA.q26,
      nSku:window.DATA.skus.length,
      sumSku26:window.DATA.skus.reduce(function(a,s){return a+s.s26;},0)}))()""")
    a25, a26 = sum(A_T25), sum(A_T26)
    chk('D1 内嵌 DATA 季度合计 == 权威季度合计',
        rec['dataQ25'] == A_T25 and rec['dataQ26'] == A_T26,
        'q25=%s q26=%s' % (rec['dataQ25'], rec['dataQ26']))
    chk('D2 JS 复算 2025 合计 == 权威 %d' % a25, rec['recalc']['s25'] == a25)
    chk('D3 JS 复算 2026 合计 == 权威 %d' % a26, rec['recalc']['s26'] == a26)
    chk('D4 JS 复算差额 == 权威 %d' % (a26 - a25), rec['recalc']['diff'] == a26 - a25)
    chk('D5 JS 复算同比 == 权威 -52.7', rec['recalc']['yoy'] == -52.7, str(rec['recalc']['yoy']))
    chk('D6 页面 2025 合计文本 = %.1f 万' % (a25 / 10000.0), rec['dom25'] == '%.1f' % (a25 / 10000.0),
        repr(rec['dom25']))
    chk('D7 页面 2026 合计文本 = %.1f 万' % (a26 / 10000.0), rec['dom26'] == '%.1f' % (a26 / 10000.0),
        repr(rec['dom26']))
    chk('D8 页面差额文本 = \u2212%.1f 万' % (abs(a26 - a25) / 10000.0),
        rec['domDiff'] == '\u2212%.1f' % (abs(a26 - a25) / 10000.0), repr(rec['domDiff']))
    chk('D9 页面同比大数字 = \u221252.7%', rec['domYoy'] == '\u221252.7%', repr(rec['domYoy']))
    chk('D10 内嵌 15 货号 2026 合计 == 页内求和', rec['sumSku26'] == sum(v[1] for v in A_SKUS.values()),
        '%d' % rec['sumSku26'])
    print('      复算对照：页面 %s 万/%s 万/差 %s 万/%s ｜ 权威 %.1f 万/%.1f 万/差 %.1f 万/-52.7%%'
          % (rec['dom25'], rec['dom26'], rec['domDiff'], rec['domYoy'],
             a25 / 10000.0, a26 / 10000.0, (a26 - a25) / 10000.0))

    # ── D11+ 源表交叉核对 ──
    chk('D11 源表总览 == 权威（2593647 / 1228060 / -1365587 / -52.7%）',
        (int(_sO['2025 合计']), int(_sO['2026 合计']), int(_sO['同比差额']), _sO['同比%'])
        == (a25, a26, a26 - a25, '-52.7%'),
        '%s / %s / %s / %s' % (_sO['2025 合计'], _sO['2026 合计'], _sO['同比差额'], _sO['同比%']))
    srcbad = {k: (SRC_MAP.get(k), A_SKUS[k]) for k in A_SKUS if SRC_MAP.get(k) !=
              (A_SKUS[k][0], A_SKUS[k][1], A_SKUS[k][1] - A_SKUS[k][0])}
    chk('D12 15 货号 源表合计/Δ == 脚本内嵌权威值', not srcbad, str(srcbad))
    qbad = {k: (SRC_Q[k], sum(SRC_Q[k][:4]), sum(SRC_Q[k][4:])) for k in SRC_Q
            if SRC_Q[k][:4] != [] and sum(SRC_Q[k][:4]) != SRC_MAP[k][0]}
    chk('D13 源表每行 25合计 == 25Q1..Q4 之和（源表内部自洽）', not qbad, str(dict(list(qbad.items())[:2])))
    pg2src = {k: (v, SRC_MAP.get(k) and SRC_MAP[k][:2]) for k, v in page_skus.items()
              if SRC_MAP.get(k) and SRC_MAP[k][:2] != tuple(v)}
    chk('D14 页面 15 格 25/26 合计 == 源表 25/26 合计', not pg2src, str(pg2src))
    print('      ⚠ 口径差异：派发口径写 MD2071-2 25合计=239200；源表与季度分列（70000+9800+'
          '72200+57200）均为 %d（相差 %d）。本看板按源表 %d，Δ=-99200（-9.9万），'
          '非 -12.9万。' % (SRC_MAP['MD2071-2'][0], 239200 - SRC_MAP['MD2071-2'][0],
                        SRC_MAP['MD2071-2'][0]))

    # ── H 业务注解（新增：角标 / 关键词 / 印证数字 / 块）──
    txt = pg.evaluate("document.body.innerText")
    chk('H1 页面含注解关键词 HPV', 'HPV' in txt)
    chk('H2 页面含注解关键词 稳定性客诉', '稳定性客诉' in txt)
    chk('H3 页面含注解关键词 HBV 超敏', 'HBV 超敏' in txt)
    nbadge = pg.evaluate("document.querySelectorAll('.note-badge').length")
    chk('H4 业务注解角标数量 ≥ 4', nbadge >= 4, '%d 个' % nbadge)
    chk('H5 「业务注解与数据印证」块存在',
        bool(pg.evaluate("!!document.getElementById('blk5')")) and '业务注解与数据印证' in txt)
    chk('H6 结论条新增归因句（非价格或竞争力问题）', '非价格或竞争力问题' in txt)
    chk('H7 三组数据印证数字齐备（1,098,000/544,800/94,500/27,760/423,300）',
        all(s in txt for s in ('1,098,000', '544,800', '94,500', '27,760', '423,300')))
    covered = pg.evaluate("""(() => {var d={};
      document.querySelectorAll('.note-badge').forEach(function(b){
        d[b.getAttribute('data-note')]=1;});
      return Object.keys(d).sort().join(',');})()""")
    chk('H8 覆盖 PFM4211*(HPV) / M5244-ZJ4*(ZJ4) / M4211-3 / MD2104-003006-DB',
        covered == 'HPV,M4211-3,ZJ4', covered)
    print('      注解角标 %d 个；命中关键词 HPV/稳定性客诉/HBV 超敏；印证数字齐备' % nbadge)

    # ── F 截图 ──
    os.makedirs(SHOT, exist_ok=True)
    full = os.path.join(SHOT, '01_整页.png')
    pg.screenshot(path=full, full_page=True)
    el = pg.query_selector('#blk2')
    el.scroll_into_view_if_needed()
    pg.wait_for_timeout(300)
    part = os.path.join(SHOT, '02_趋势小图阵.png')
    el.screenshot(path=part)
    card = pg.query_selector('#grid .card')
    one = os.path.join(SHOT, '03_单格sparkline.png')
    card.screenshot(path=one)
    chart = pg.query_selector('svg.main-line')
    mc = os.path.join(SHOT, '04_双线主图.png')
    chart.screenshot(path=mc)
    bars = os.path.join(SHOT, '05_同比条形.png')
    pg.query_selector('svg.bars').screenshot(path=bars)
    from PIL import Image
    for f in (full, part, one, mc, bars):
        im = Image.open(f)
        print('      截图 %s  %dx%d' % (os.path.basename(f), im.size[0], im.size[1]))
    chk('F1 截图 ≥2 张且尺寸正常',
        all(os.path.getsize(f) > 8000 for f in (full, part, one, mc, bars)))
    b.close()

# ── G mtime 未变 ──
for p, m in EXPECT.items():
    chk('G1 未改动 %s（mtime 未变）' % os.path.relpath(p, ROOT), int(os.path.getmtime(p)) == m,
        'now=%d want=%d' % (int(os.path.getmtime(p)), m))

print('\n' + '=' * 62)
print('验收：%d 项通过 / %d 项失败' % (OKN[0] - len(FAIL), len(FAIL)))
if FAIL:
    print('失败项：' + '；'.join(FAIL))
    sys.exit(1)
print('全部通过 ✅')
