#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
宝锐工作台 HTML 结构自动检查 —— 12 条规则
本地脚本，零云平台依赖（仅调用本地 node 做 JS 语法检查）。
每次生成 / 修改工作台 HTML 后跑一遍，提前拦住语法 bug。

用法：
  python3 html_lint.py                     # 检查整个工作台（等价 --all）
  python3 html_lint.py <文件.html>         # 检查单个文件
  python3 html_lint.py <目录>              # 递归检查目录下所有 .html
  python3 html_lint.py --all               # 递归扫描整个工作台根目录
  python3 html_lint.py <文件> --base <路径>  # 覆盖根目录（用于解析绝对路径）

退出码：
  0 = 通过（无 P0/P1）
  1 = 存在 P0（阻断级，页面会坏）
  2 = 存在 P1（高危，功能异常），无 P0
  3 = 存在 P2（规范提示），无 P0/P1
"""

import os
import re
import sys
import subprocess
import tempfile

# 工作台根目录：绝对路径（/数据/xxx 等）据此解析
BASE = '/Users/liuxinyuan/Desktop/Hermes输出-工作类'

# 结构性容器标签（规则3 计数配对检查）
PAIRED_TAGS = ['div', 'table', 'ul', 'ol', 'form', 'select',
               'iframe', 'tbody', 'thead', 'tfoot', 'tr', 'td', 'th', 'span']

# 失配即页面结构崩坏的标签（P0），其余为 P1
STRUCTURAL_P0 = {'div', 'table', 'iframe'}

# 需跳过的目录（批量扫描时）
SKIP_DIRS = {'备份', '备份归档', '__pycache__', 'node_modules', '.git',
             '会话存档', '产品验证资料', '培训资料'}


class Issue(object):
    """一条检查结果"""
    def __init__(self, rule, sev, msg, line=0, snippet=''):
        self.rule = rule
        self.sev = sev
        self.msg = msg
        self.line = line
        self.snippet = snippet


SEV_ORDER = {'P0': 0, 'P1': 1, 'P2': 2}

# node 是否可用（规则1 探测后更新；不可用时规则12 才启用括号平衡后备）
NODE_AVAILABLE = True

# CloudBase 部署应用的跨应用链接前缀（本地无对应文件，规则7 排除）
DEPLOY_PREFIXES = ('/workbench/', '/training/', '/cases/')


def lineno(content, pos):
    if pos < 0 or pos > len(content):
        return 0
    return content[:pos].count('\n') + 1


def clip(s, n=80):
    s = (s or '').strip()
    s = re.sub(r'\s+', ' ', s)
    return s[:n] + ('…' if len(s) > n else '')


def extract_inline_scripts(content):
    """抽取所有内联 <script> 块（跳过带 src= 的外部脚本）。返回 [(offset, js)]。"""
    out = []
    for m in re.finditer(r'<script\b([^>]*)>(.*?)</script>', content,
                         re.S | re.I):
        attrs = m.group(1)
        if re.search(r'\bsrc\s*=', attrs, re.I):
            continue
        out.append((m.start(), m.group(2)))
    return out


def extract_functions(content):
    """抽取所有具名函数定义。返回 [(name, params, body, offset)]。"""
    funcs = []
    for m in re.finditer(r'\bfunction\s+([A-Za-z_$][\w$]*)\s*\(([^)]*)\)\s*\{',
                         content):
        name, params = m.group(1), m.group(2)
        body_start = m.end()
        depth = 1
        i = body_start
        while i < len(content) and depth > 0:
            c = content[i]
            if c == '{':
                depth += 1
            elif c == '}':
                depth -= 1
                if depth == 0:
                    break
            i += 1
        funcs.append((name, params, content[body_start:i], m.start()))
    return funcs


# ---------------------------------------------------------------------------
# 规则 1：JS 语法（node --check）
# ---------------------------------------------------------------------------
def rule1_js_syntax(content):
    global NODE_AVAILABLE
    issues = []
    scripts = extract_inline_scripts(content)
    if not scripts:
        return issues
    merged = '\n;\n'.join(js for _, js in scripts)
    try:
        fd, path = tempfile.mkstemp(suffix='.js', prefix='html_lint_')
        with os.fdopen(fd, 'w', encoding='utf-8') as f:
            f.write(merged)
        r = subprocess.run(['node', '--check', path],
                           capture_output=True, text=True)
        os.unlink(path)
        if r.returncode != 0:
            located = False
            for i, (off, js) in enumerate(scripts):
                fd2, p2 = tempfile.mkstemp(suffix='.js', prefix='html_lint_')
                with os.fdopen(fd2, 'w', encoding='utf-8') as f:
                    f.write(js)
                r2 = subprocess.run(['node', '--check', p2],
                                    capture_output=True, text=True)
                os.unlink(p2)
                if r2.returncode != 0:
                    err = (r2.stderr or '').strip().split('\n')
                    issues.append(Issue(
                        '规则1 JS语法', 'P0',
                        '第 %d 个内联 <script> 语法错误：%s' % (i + 1,
                                                               err[0] if err else 'unknown'),
                        lineno(content, off), clip(js)))
                    located = True
                    break
            if not located:
                err = (r.stderr or '').strip().split('\n')
                issues.append(Issue(
                    '规则1 JS语法', 'P0',
                    '内联 JS 语法错误：%s' % (err[0] if err else 'unknown'),
                    0, ''))
    except FileNotFoundError:
        NODE_AVAILABLE = False
        issues.append(Issue('规则1 JS语法', 'P2',
                            '未找到 node，跳过 JS 语法检查（brew install node）',
                            0, ''))
    return issues


# ---------------------------------------------------------------------------
# 规则 2：<script> 标签配对
# ---------------------------------------------------------------------------
def rule2_script_pairs(content):
    issues = []
    opens = len(re.findall(r'<script\b', content, re.I))
    closes = len(re.findall(r'</script\s*>', content, re.I))
    if opens != closes:
        issues.append(Issue(
            '规则2 script配对', 'P0',
            '<script> 开 %d / 闭 %d 不一致（后续内容会被吞进 JS）'
            % (opens, closes), 0, ''))
    return issues


# ---------------------------------------------------------------------------
# 规则 3：结构性标签配对（剔除 script/注释，避免误计 JS 模板字符串）
# ---------------------------------------------------------------------------
def rule3_tag_pairs(content):
    issues = []
    s = re.sub(r'<script\b.*?</script>', '', content, flags=re.S | re.I)
    s = re.sub(r'<!--.*?-->', '', s, flags=re.S)
    for tag in PAIRED_TAGS:
        opens = len(re.findall(r'<%s\b[^>]*>' % tag, s, re.I))
        self_close = len(re.findall(r'<%s\b[^>]*/\s*>' % tag, s, re.I))
        closes = len(re.findall(r'</%s\s*>' % tag, s, re.I))
        opens -= self_close
        if opens != closes:
            sev = 'P0' if tag in STRUCTURAL_P0 else 'P1'
            issues.append(Issue(
                '规则3 标签配对', sev,
                '<%s> 开 %d / 闭 %d 不一致' % (tag, opens, closes), 0, ''))
    return issues


# ---------------------------------------------------------------------------
# 规则 4：HTML 属性引号嵌套（onclick="fn("x")" 类）
# ---------------------------------------------------------------------------
def rule4_attr_quotes(content):
    issues = []
    # 剔除 script 内容和注释，只检查真正的静态 HTML 属性
    # （JS 字符串里拼接生成的 onclick 是合法的，不属于此规则检查范围）
    s = re.sub(r'<script\b.*?</script>', '', content, flags=re.S | re.I)
    s = re.sub(r'<!--.*?-->', '', s, flags=re.S)
    for m in re.finditer(r'\b(on\w+)\s*=\s*"', s, re.I):
        attr = m.group(1)
        i = m.end()
        j = i
        while j < len(s):
            if s[j] == '"' and (j == 0 or s[j - 1] != '\\'):
                break
            j += 1
        if j >= len(s):
            continue
        after = s[j + 1:j + 2] if j + 1 < len(s) else ''
        if after and after not in ' \t\n\r/>':
            issues.append(Issue(
                '规则4 属性引号嵌套', 'P1',
                '%s 属性值内双引号未转义（属性提前截断，事件失效）' % attr,
                lineno(content, m.start()),
                clip(s[m.start():m.start() + 60])))
    return issues


# ---------------------------------------------------------------------------
# 规则 5：中文引号混入 JS
# ---------------------------------------------------------------------------
def rule5_cjk_quotes(content):
    issues = []
    for i, (off, js) in enumerate(extract_inline_scripts(content)):
        for ch in ['\u201c', '\u201d', '\u2018', '\u2019']:
            if ch in js:
                issues.append(Issue(
                    '规则5 中文引号', 'P1',
                    '第 %d 个 script 含中文弯引号 %s（JS 解析为标识符 → SyntaxError）'
                    % (i + 1, ch),
                    lineno(content, off), ''))
                break
    return issues


# ---------------------------------------------------------------------------
# 路径解析（规则6/7 共用）
# ---------------------------------------------------------------------------
def resolve_path(raw, html_dir, base):
    raw = raw.strip()
    if not raw:
        return None
    if raw.startswith(('http://', 'https://', '//', '#', 'javascript:', 'data:')):
        return None
    if raw.startswith(('/api/', 'api/')):
        return None  # API 端点，非静态文件
    if raw.startswith(DEPLOY_PREFIXES):
        return None  # CloudBase 部署应用跨链接，本地无文件
    if re.search(r"['\"]\s*\+\s*|\\\$|\$\{|[+\"]\s*['\"]", raw):
        return None  # 动态拼接，无法静态解析
    clean = raw.split('?')[0].split('#')[0]
    if clean.startswith('/'):
        return os.path.normpath(os.path.join(base, clean.lstrip('/')))
    if clean.startswith(('./', '../')):
        return os.path.normpath(os.path.join(html_dir, clean))
    return None


# ---------------------------------------------------------------------------
# 规则 6：fetch 数据文件路径存在性
# ---------------------------------------------------------------------------
def rule6_fetch_paths(content, path, base):
    issues = []
    html_dir = os.path.dirname(os.path.realpath(path))
    seen = set()
    for m in re.finditer(r"fetch\s*\(\s*(['\"])(.*?)\1", content):
        raw = m.group(2)
        fp = resolve_path(raw, html_dir, base)
        if fp is None or fp in seen:
            continue
        seen.add(fp)
        if not os.path.exists(fp):
            # 有 .catch 兜底 = 可选数据缺失（非阻断）；无兜底 = 必需数据会断链
            # 向后扫描到下一个 function 定义（函数边界），跨行 .catch 也能检测到
            tail = content[m.start():m.start() + 3000]
            fm = re.search(r'\nfunction\s+', tail)
            if fm:
                tail = tail[:fm.start()]
            has_catch = '.catch' in tail
            issues.append(Issue(
                '规则6 fetch路径', 'P2' if has_catch else 'P1',
                'fetch 数据文件缺失：%s%s' % (
                    raw, '（有.catch兜底，非阻断）' if has_catch else '（无兜底，会断链）'),
                lineno(content, m.start()), ''))
    return issues


# ---------------------------------------------------------------------------
# 规则 7：href / src 链接路径存在性
# ---------------------------------------------------------------------------
def rule7_link_paths(content, path, base):
    issues = []
    html_dir = os.path.dirname(os.path.realpath(path))
    seen = set()
    for m in re.finditer(r'(?:href|src)\s*=\s*"([^"]+)"', content):
        raw = m.group(1)
        fp = resolve_path(raw, html_dir, base)
        if fp is None or fp in seen:
            continue
        seen.add(fp)
        if not os.path.exists(fp):
            issues.append(Issue(
                '规则7 链接路径', 'P1',
                '链接目标缺失：%s' % raw,
                lineno(content, m.start()), ''))
    return issues


# ---------------------------------------------------------------------------
# 规则 8：同一元素多次 innerHTML 赋值
# ---------------------------------------------------------------------------
def rule8_innerhtml_dup(content):
    issues = []
    # 按函数作用域检测：同一函数内多次写同一容器才是真覆盖（Pitfall 2）
    # 不同函数各写一次同一容器 = SPA 正常 render，不报
    for name, params, body, off in extract_functions(content):
        counts = {}
        for m in re.finditer(
                r"(?:getElementById\(|querySelector\()?\s*"
                r"['\"]([A-Za-z_][\w-]*)['\"]\s*\)?\s*\.innerHTML\s*=",
                body):
            key = m.group(1)
            counts.setdefault(key, []).append(m.start())
        for key, positions in counts.items():
            if len(positions) > 1:
                issues.append(Issue(
                    '规则8 innerHTML重复赋值', 'P2',
                    '函数 %s() 内元素 #%s 被 innerHTML 赋值 %d 次（多为 loading→结果/if分支，需人工确认是否真覆盖）'
                    % (name, key, len(positions)),
                    lineno(content, off + positions[-1]), ''))
    return issues


# ---------------------------------------------------------------------------
# 规则 9：inline onclick 调用的函数用了 event 但未传参
# ---------------------------------------------------------------------------
def rule9_event_param(content):
    issues = []
    inline_fns = set()
    for m in re.finditer(r'\bon\w+\s*=\s*"([^"]*)"', content, re.I):
        for fn in re.findall(r'([A-Za-z_$][\w$]*)\s*\(', m.group(1)):
            inline_fns.add(fn)
    if not inline_fns:
        return issues
    for name, params, body, off in extract_functions(content):
        if name not in inline_fns:
            continue
        param_list = [p.strip() for p in params.split(',') if p.strip()]
        if re.search(r'\bevent\s*\.', body) and 'event' not in param_list:
            issues.append(Issue(
                '规则9 event未传参', 'P1',
                'inline 事件调用的 %s() 用了 event 但参数未声明（onclick 不自动传 event）'
                % name,
                lineno(content, off), ''))
    return issues


# ---------------------------------------------------------------------------
# 规则 10：HermesTable 容器必须 <div> 而非 <table>
# ---------------------------------------------------------------------------
def rule10_hermestable_container(content):
    issues = []
    for m in re.finditer(r'<table\b[^>]*\bid\s*=\s*["\'](\w*Table)["\']',
                         content, re.I):
        issues.append(Issue(
            '规则10 HermesTable容器', 'P2',
            'HermesTable 容器 #%s 用了 <table>，应改 <div>（否则内容被浏览器弹出）'
            % m.group(1),
            lineno(content, m.start()), ''))
    return issues


# ---------------------------------------------------------------------------
# 规则 11：同一函数内 var 变量重复声明
# ---------------------------------------------------------------------------
def rule11_var_dup(content):
    issues = []
    LOOP_VARS = set('ijknmostexy')
    for name, params, body, off in extract_functions(content):
        # 找所有 var 声明及其位置
        decls = [(m.group(1), m.start())
                 for m in re.finditer(r'\bvar\s+([A-Za-z_$][\w$]*)', body)]
        pos_by_name = {}
        for n, pos in decls:
            pos_by_name.setdefault(n, []).append(pos)
        for n, positions in pos_by_name.items():
            if len(n) == 1 and n in LOOP_VARS:
                continue
            if len(positions) < 2:
                continue
            # 精确检测 Pitfall 3 模式：两次 var 声明之间有累加赋值
            for a, b in zip(positions, positions[1:]):
                between = body[a:b]
                if re.search(r'\b%s\s*(\+=|-=|\+\+|--|\.push)' % re.escape(n),
                             between):
                    issues.append(Issue(
                        '规则11 var重复声明', 'P2',
                        '函数 %s() 内 %s 累加后又被 var 重新声明（会重置累加结果）'
                        % (name, n),
                        lineno(content, off), ''))
                    break
    return issues


# ---------------------------------------------------------------------------
# 规则 12：括号平衡（node 通过时仅作提示）
# ---------------------------------------------------------------------------
def rule12_brackets(content):
    # node --check 已权威检测 JS 语法（含括号），本规则仅在 node 不可用时作后备
    if NODE_AVAILABLE:
        return []
    issues = []
    for i, (off, js) in enumerate(extract_inline_scripts(content)):
        s = re.sub(r"'(?:\\.|[^'\\])*'", '', js)
        s = re.sub(r'"(?:\\.|[^"\\])*"', '', s)
        s = re.sub(r'`(?:\\.|[^`\\])*`', '', s)
        s = re.sub(r'//[^\n]*', '', s)
        s = re.sub(r'/\*.*?\*/', '', s, flags=re.S)
        for o, c, name in [('{', '}', '花括号'),
                           ('(', ')', '圆括号'),
                           ('[', ']', '方括号')]:
            if s.count(o) != s.count(c):
                issues.append(Issue(
                    '规则12 括号平衡', 'P2',
                    '第 %d 个 script %s 开%d/闭%d（node 通过则多为模板字符串，仅提示）'
                    % (i + 1, name, s.count(o), s.count(c)),
                    lineno(content, off), ''))
    return issues


# ---------------------------------------------------------------------------
# 主检查
# ---------------------------------------------------------------------------
ALL_RULES = [
    rule1_js_syntax,
    rule2_script_pairs,
    rule3_tag_pairs,
    rule4_attr_quotes,
    rule5_cjk_quotes,
    rule6_fetch_paths,
    rule7_link_paths,
    rule8_innerhtml_dup,
    rule9_event_param,
    rule10_hermestable_container,
    rule11_var_dup,
    rule12_brackets,
]


def check_file(path, base):
    issues = []
    try:
        with open(path, encoding='utf-8', errors='replace') as f:
            content = f.read()
    except OSError as e:
        return [], '读取失败：%s' % e
    for rule_fn in ALL_RULES:
        try:
            if rule_fn in (rule6_fetch_paths, rule7_link_paths):
                issues.extend(rule_fn(content, path, base))
            else:
                issues.extend(rule_fn(content))
        except Exception as e:
            issues.append(Issue('内部错误', 'P2',
                                '规则 %s 执行异常：%s' % (rule_fn.__name__, e),
                                0, ''))
    return issues, None


def collect_files(targets, base):
    files = []
    for t in targets:
        if os.path.isfile(t):
            if t.lower().endswith('.html'):
                files.append(t)  # 显式单文件：不跳过 backup
        elif os.path.isdir(t):
            for root, dirs, names in os.walk(t):
                dirs[:] = [d for d in dirs if d not in SKIP_DIRS]
                for n in names:
                    full = os.path.join(root, n)
                    if n.lower().endswith('.html') and \
                            'backup' not in n.lower() and \
                            not os.path.islink(full):
                        files.append(full)
    return sorted(set(os.path.abspath(f) for f in files))


def main():
    args = sys.argv[1:]
    base = BASE
    targets = []
    i = 0
    while i < len(args):
        a = args[i]
        if a == '--base':
            base = args[i + 1]
            i += 2
            continue
        elif a == '--all':
            targets.append(base)
            i += 1
            continue
        elif a in ('--help', '-h'):
            print(__doc__)
            return 0
        else:
            targets.append(a)
            i += 1

    if not targets:
        targets.append(base)

    files = collect_files(targets, base)
    if not files:
        print('未找到任何 .html 文件。')
        return 0

    sev_emoji = {'P0': '🔴', 'P1': '🟡', 'P2': '🟢'}
    total = {'P0': 0, 'P1': 0, 'P2': 0}
    bad_files = []

    print('🔍 宝锐工作台 HTML 结构检查 · 12 规则')
    print('根目录：%s' % base)
    print('共 %d 个文件\n' % len(files))

    for path in files:
        rel = os.path.relpath(path, base) if path.startswith(base) else path
        issues, err = check_file(path, base)
        if err:
            print('  ⚠️  %s：%s' % (rel, err))
            continue
        if not issues:
            print('  ✅ %s' % rel)
            continue
        issues.sort(key=lambda x: SEV_ORDER.get(x.sev, 9))
        bad_files.append(rel)
        print('  ❌ %s' % rel)
        for it in issues:
            total[it.sev] = total.get(it.sev, 0) + 1
            loc = ('L%d' % it.line) if it.line else '   '
            print('      %s [%s] %s：%s %s' % (
                sev_emoji.get(it.sev, '•'), it.sev, it.rule, it.msg,
                ('(' + loc + ')') if it.line else ''))
            if it.snippet:
                print('           ↳ %s' % it.snippet)

    print('\n' + '─' * 50)
    print('汇总：P0=%d  P1=%d  P2=%d  问题文件=%d/%d' % (
        total['P0'], total['P1'], total['P2'], len(bad_files), len(files)))

    if total['P0']:
        print('🔴 存在 P0 阻断级问题，页面会坏，禁止部署。')
        return 1
    if total['P1']:
        print('🟡 存在 P1 高危问题，建议修复后再部署。')
        return 2
    if total['P2']:
        print('🟢 存在 P2 规范提示，可择机处理。')
        return 3
    print('✅ 全部通过。')
    return 0


if __name__ == '__main__':
    sys.exit(main())
