#!/usr/bin/env python3
"""
edit-redmarked-xlsx.py — 安全编辑「已标红（WPS规范化）」的 xlsx，红色 run 一个字不碰。

背景（2026-06-18 世茂实证）：
  WPS 另存后的好 xlsx 用 sharedStrings.xml + 富文本红 <r> run 存储「某条标红」。
  对它 openpyxl.load_workbook→改→save 会把整表打回 inlineStr、红色 run 全部归零、
  sharedStrings.xml 消失，Excel 重新报「需要修复」——成果一键作废。
  正解：在 sharedStrings.xml 的 XML 层做外科手术，只改目标 <t> 文字，红 <r> run 原样保留。

本脚本提供：
  1) --verify           只读·五查（本地验不了 Excel，这是能自动做的最强保证）
  2) 可 import 的工具函数 surgical_edit_si() / delete_run_and_renumber() / repack()
     —— 已知正确的实现，未来 session 直接 import 或照抄，别重新踩坑。

⚠️ 红色 = rgb="FFFF0000"。重打包必须 [Content_Types].xml 在 zip 第一项。
⚠️ 改前先把 WPS 好版本另存 _bak_ 备份；改完五查全过再发 Maggie 用 Excel 终判。

用法：
  # 五查（改后必跑）
  python3 edit-redmarked-xlsx.py --verify 改后.xlsx --baseline WPS好基线.xlsx
  # 列出每个格子用的 sharedString 索引（定位「要改哪个格→改第几条 si」）
  python3 edit-redmarked-xlsx.py --map 文件.xlsx [--sheet sheet1.xml]
  # 打印某条 si 的 run 结构（看哪些 <r> 是红色、文字开头）
  python3 edit-redmarked-xlsx.py --show-si 文件.xlsx 47
"""
import sys, os, re, zipfile, shutil, argparse, tempfile
from lxml import etree

NS = "{http://schemas.openxmlformats.org/spreadsheetml/2006/main}"
RED = "FFFF0000"


# ---------- 只读探针 ----------
def red_run_count(xlsx):
    """数红色富文本 run 数（精确匹配 rgb="FFFF0000"）。
    ⚠️ 2026-06-22 教训：务必用精确 rgb="FFFF0000" 匹配，绝不能用
    `'FF0000' in etree.tostring(rpr)` 子串匹配——黑色 FF000000 里也含 'F0000'，
    会把黑字 run 误判成红字，导致 K列长单元格被误报"整格泛红"。
    """
    z = zipfile.ZipFile(xlsx)
    if "xl/sharedStrings.xml" not in z.namelist():
        # 退化成 inlineStr 了——红色多半已丢，去 sheet 里数
        total = 0
        for n in z.namelist():
            if re.match(r"xl/worksheets/sheet\d+\.xml", n):
                total += len(re.findall(rf'rgb="{RED}"', z.read(n).decode()))
        return total, False  # False = 没有 sharedStrings（危险信号）
    ss = z.read("xl/sharedStrings.xml").decode()
    return len(re.findall(rf'rgb="{RED}"', ss)), True


def cell_si_map(xlsx, sheet="xl/worksheets/sheet1.xml"):
    """返回 {单元格坐标: sharedString索引}，用于把「改哪个格」翻成「改第几条 si」。"""
    z = zipfile.ZipFile(xlsx)
    sx = z.read(sheet).decode()
    out = {}
    for m in re.finditer(r'<c r="([A-Z]+\d+)"[^>]*\bt="s"[^>]*>\s*<v>(\d+)</v>', sx):
        out[m.group(1)] = int(m.group(2))
    return out


def show_si(xlsx, idx):
    """打印第 idx 条 si 的 run 结构（红/黑 + 文字开头），定位要改/要删哪个 run。"""
    z = zipfile.ZipFile(xlsx)
    root = etree.fromstring(z.read("xl/sharedStrings.xml"))
    si = root.findall(f"{NS}si")[idx]
    print(f"=== si[{idx}] ===")
    for i, ch in enumerate(si):
        tag = ch.tag.replace(NS, "")
        if tag == "r":
            rpr = ch.find(f"{NS}rPr")
            t = ch.find(f"{NS}t")
            is_red = rpr is not None and RED in etree.tostring(rpr, encoding="unicode")
            txt = (t.text or "")[:55] if t is not None else ""
            print(f"  [{i}] {'🔴红' if is_red else '  黑'} | {txt!r}")
        elif tag == "t":
            print(f"  [{i}] 纯t | {(ch.text or '')[:55]!r}")


def verify(xlsx, baseline=None, expect_red=None):
    """五查：sharedStrings在 / 红色数 / zip+XML完整 / (可选)与基线同构。返回 True/False。"""
    ok = True
    z = zipfile.ZipFile(xlsx)
    names = z.namelist()

    # ① sharedStrings 仍在
    has_ss = "xl/sharedStrings.xml" in names
    print(f"① sharedStrings.xml: {'✅在' if has_ss else '❌丢失(openpyxl毁了富文本!)'}")
    ok &= has_ss

    # ② 红色 run 数
    n_red, _ = red_run_count(xlsx)
    tail = f"（预期 {expect_red}）" if expect_red is not None else ""
    match = (expect_red is None) or (n_red == expect_red)
    print(f"② 红色run数: {n_red}{tail} {'✅' if match else '❌'}")
    ok &= match

    # ③ zip 完整 + 所有 XML 部件可解析
    bad = z.testzip()
    parts_ok, parts_bad = 0, []
    for n in names:
        if n.endswith(".xml") or n.endswith(".rels"):
            try:
                etree.fromstring(z.read(n)); parts_ok += 1
            except Exception as e:
                parts_bad.append((n, str(e)[:50]))
    print(f"③ zip完整={'✅' if bad is None else '❌'+str(bad)}; "
          f"XML部件 {parts_ok}个OK"
          + (f" ❌{parts_bad}" if parts_bad else " ✅"))
    ok &= (bad is None) and not parts_bad

    # ④ Content_Types 必须第一项（严格解析器要求）
    first = names[0] if names else ""
    ct_first = first == "[Content_Types].xml"
    print(f"④ [Content_Types].xml 在首位: {'✅' if ct_first else '⚠️ 实为 '+first}")
    # 不计入硬失败（Excel/WPS 宽容），仅告警
    if not ct_first:
        print("   ↳ 严格解析器(LibreOffice)会报 source file could not be loaded；建议重打包置首。")

    # ⑤ 与基线同构（部件清单）
    if baseline:
        zb = set(zipfile.ZipFile(baseline).namelist())
        zo = set(names)
        only_mine = {x for x in zo - zb if not x.endswith("/")}
        only_base = {x for x in zb - zo if not x.endswith("/")}
        iso = not only_mine and not only_base
        print(f"⑤ 与基线部件同构: {'✅' if iso else '❌'} "
              + (f"我多:{only_mine} 基线多:{only_base}" if not iso else "(差异仅空目录条目可接受)"))
        ok &= iso

    print(f"\n{'✅ 五查通过——可发 Maggie 用 Excel 终判' if ok else '❌ 有项未过——先修再发'}")
    return ok


# ---------- 编辑工具（import 用；已验证正确，照抄别重写） ----------
def surgical_edit_si(work_dir, edits: dict):
    """
    在解压目录 work_dir 的 xl/sharedStrings.xml 上，按 {si索引: 新纯文本} 改纯文本格。
    仅适用于「纯文本 si」（无红 run）。含红 run 的格用下面 delete_run_and_renumber 或手写。
    """
    ssp = os.path.join(work_dir, "xl", "sharedStrings.xml")
    tree = etree.parse(ssp)
    sis = tree.getroot().findall(f"{NS}si")
    for idx, new_text in edits.items():
        si = sis[idx]
        for c in list(si):
            si.remove(c)
        t = etree.SubElement(si, f"{NS}t")
        t.set("{http://www.w3.org/XML/1998/namespace}space", "preserve")
        t.text = new_text
    tree.write(ssp, xml_declaration=True, encoding="UTF-8", standalone=True)


def delete_run_and_renumber(work_dir, si_idx, match_prefix, renum: dict):
    """
    含红 run 的格：删掉文字以 match_prefix 开头的那个 <r>（连同其红色），
    再按 renum {旧前缀: 新前缀} 顺移后续编号。红 run 之外的一律不动。
    例：删 "9. " 那条红，renum={"10. ":"9. ","11. ":"10. ","12. ":"11. "}
    """
    ssp = os.path.join(work_dir, "xl", "sharedStrings.xml")
    tree = etree.parse(ssp)
    si = tree.getroot().findall(f"{NS}si")[si_idx]
    target = None
    for r in si.findall(f"{NS}r"):
        t = r.find(f"{NS}t")
        if t is not None and t.text and t.text.startswith(match_prefix):
            target = r; break
    if target is None:
        raise ValueError(f"si[{si_idx}] 没找到以 {match_prefix!r} 开头的 run")
    si.remove(target)
    for r in si.findall(f"{NS}r"):
        t = r.find(f"{NS}t")
        if t is not None and t.text:
            for old, new in renum.items():
                if t.text.startswith(old):
                    t.text = new + t.text[len(old):]; break
    tree.write(ssp, xml_declaration=True, encoding="UTF-8", standalone=True)


def repack(work_dir, out_xlsx):
    """规范重打包：[Content_Types].xml 第一，_rels/ 次之，其余原序。ZIP_DEFLATED。"""
    if os.path.exists(out_xlsx):
        os.remove(out_xlsx)
    files = []
    for folder, _, fs in os.walk(work_dir):
        for fn in fs:
            full = os.path.join(folder, fn)
            arc = os.path.relpath(full, work_dir).replace(os.sep, "/")
            files.append((arc, full))

    def key(it):
        a = it[0]
        if a == "[Content_Types].xml": return (0, a)
        if a.startswith("_rels/"): return (1, a)
        return (2, a)

    files.sort(key=key)
    with zipfile.ZipFile(out_xlsx, "w", zipfile.ZIP_DEFLATED) as zf:
        for arc, full in files:
            zf.write(full, arc)
    return out_xlsx


def extract(xlsx):
    """解压到新临时目录，返回目录路径。"""
    d = tempfile.mkdtemp(prefix="redxlsx_")
    with zipfile.ZipFile(xlsx) as z:
        z.extractall(d)
    return d


# ---------- CLI ----------
def main():
    ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
    ap.add_argument("--verify", metavar="XLSX", help="五查（只读）")
    ap.add_argument("--baseline", metavar="GOOD", help="WPS好基线，用于同构对比")
    ap.add_argument("--expect-red", type=int, help="改后预期红色run数（删1条红=基线-1）")
    ap.add_argument("--map", metavar="XLSX", help="列出单元格→sharedString索引")
    ap.add_argument("--sheet", default="xl/worksheets/sheet1.xml")
    ap.add_argument("--show-si", nargs=2, metavar=("XLSX", "IDX"), help="打印某条 si 的 run 结构")
    args = ap.parse_args()

    if args.verify:
        sys.exit(0 if verify(args.verify, args.baseline, args.expect_red) else 1)
    if args.map:
        for coord, idx in sorted(cell_si_map(args.map, args.sheet).items(),
                                 key=lambda kv: (kv[0][0], int(re.sub(r"\D", "", kv[0])))):
            print(f"  {coord:>5} -> si[{idx}]")
        return
    if args.show_si:
        show_si(args.show_si[0], int(args.show_si[1]))
        return
    ap.print_help()


if __name__ == "__main__":
    main()
