"""xlsx のどこが重いのかを、Excel を使わずに数える。

使い方:
    python book-check.py ブック.xlsx

やっていること:
    xlsx は zip なので、中の部品を開いて大きい順に並べる。
    シートごとに、宣言している範囲と、値が本当に入っている範囲を比べる。
    値がなく書式だけあるセルの数、条件付き書式の本数、結合の数を数える。

Python 3.9 以降。追加のライブラリは要らない。
"""

from __future__ import annotations

import os
import re
import sys
import zipfile
from xml.etree import ElementTree as ET

NS = "{http://schemas.openxmlformats.org/spreadsheetml/2006/main}"
REL_NS = "{http://schemas.openxmlformats.org/package/2006/relationships}"
CELL_RE = re.compile(r"([A-Z]+)([0-9]+)")


def col_no(letters: str) -> int:
    n = 0
    for ch in letters:
        n = n * 26 + (ord(ch) - 64)
    return n


def col_name(n: int) -> str:
    s = ""
    while n > 0:
        n, r = divmod(n - 1, 26)
        s = chr(65 + r) + s
    return s


def sheet_names(z: zipfile.ZipFile) -> dict[str, str]:
    """シートの表示名と、xl/worksheets の中のファイル名を対応づける。"""
    try:
        wb = ET.fromstring(z.read("xl/workbook.xml"))
        rels = ET.fromstring(z.read("xl/_rels/workbook.xml.rels"))
    except KeyError:
        return {}
    target = {r.get("Id"): r.get("Target") for r in rels}
    out = {}
    for s in wb.iter(NS + "sheet"):
        rid = s.get("{http://schemas.openxmlformats.org/officeDocument/2006/relationships}id")
        t = target.get(rid, "")
        if t.startswith("/xl/"):
            t = t[1:]
        elif not t.startswith("xl/"):
            t = "xl/" + t
        out[t] = s.get("name")
    return out


def scan_sheet(data: bytes) -> dict:
    """1枚ぶんの xml を舐めて、範囲とセルの数を数える。"""
    res = {"dim": "", "max_row": 0, "max_col": 0, "val_cells": 0,
           "fmt_only": 0, "cf": 0, "cf_rules": 0, "merged": 0, "rows": 0}
    for _ev, el in ET.iterparse(_feed(data), events=("end",)):
        tag = el.tag
        if tag == NS + "dimension":
            res["dim"] = el.get("ref", "")
        elif tag == NS + "c":
            ref = el.get("r") or ""
            m = CELL_RE.match(ref)
            has_value = el.find(NS + "v") is not None or el.find(NS + "f") is not None \
                or el.find(NS + "is") is not None
            if has_value:
                res["val_cells"] += 1
                if m:
                    res["max_row"] = max(res["max_row"], int(m.group(2)))
                    res["max_col"] = max(res["max_col"], col_no(m.group(1)))
            elif el.get("s"):
                res["fmt_only"] += 1
            el.clear()
        elif tag == NS + "row":
            res["rows"] += 1
            el.clear()
        elif tag == NS + "conditionalFormatting":
            res["cf"] += 1
            res["cf_rules"] += len(el.findall(NS + "cfRule"))
            el.clear()
        elif tag == NS + "mergeCell":
            res["merged"] += 1
            el.clear()
    return res


def _feed(data: bytes):
    """iterparse に渡すためのファイルのような入れ物。"""
    import io
    return io.BytesIO(data)


def human(n: int) -> str:
    return f"{n:,}"


def main(argv: list[str]) -> int:
    if len(argv) < 2:
        print(__doc__)
        return 2
    try:
        sys.stdout.reconfigure(encoding="utf-8")
    except Exception:
        pass
    path = argv[1]
    with zipfile.ZipFile(path) as z:
        total = sum(i.file_size for i in z.infolist())
        print(os.path.basename(path))
        print("  ファイルの大きさ %s KB / 中の部品を広げると %s KB"
              % (human(os.path.getsize(path) // 1024), human(total // 1024)))
        print("")

        print("  大きい部品")
        big = sorted(z.infolist(), key=lambda i: -i.file_size)[:6]
        for i in big:
            pct = i.file_size * 100 / total if total else 0
            print("    %-40s %8s KB  %4.0f%%"
                  % (i.filename, human(i.file_size // 1024), pct))

        hint = []
        names = sheet_names(z)
        media = [i for i in z.infolist() if i.filename.startswith("xl/media/")]
        print("\n  シート")
        for fn, disp in names.items():
            try:
                r = scan_sheet(z.read(fn))
            except KeyError:
                continue
            real = ""
            if r["max_row"]:
                real = "A1:%s%d" % (col_name(r["max_col"]), r["max_row"])
            print("    %s" % disp)
            print("      宣言している範囲 %-16s 値が入っている範囲 %s"
                  % (r["dim"] or "なし", real or "なし"))
            print("      値のあるセル %s / 書式だけのセル %s / 行の数 %s"
                  % (human(r["val_cells"]), human(r["fmt_only"]), human(r["rows"])))
            print("      条件付き書式 %s 本（範囲 %s か所） / 結合 %s か所"
                  % (human(r["cf_rules"]), human(r["cf"]), human(r["merged"])))
            muda = r["rows"] - r["max_row"]
            if muda > 0:
                print("      値の最後より下に %s 行ぶんの書式が残っている" % human(muda))
                hint.append("%s の %d 行目から下を、行ごと選んで削除して保存し直す"
                            % (disp, r["max_row"] + 1))
            if r["fmt_only"] > r["val_cells"]:
                hint.append("%s は書式だけのセルのほうが多い。範囲を選んで書式のクリアをかける"
                            % disp)
            if r["cf_rules"] >= 50:
                hint.append("%s の条件付き書式が %s 本ある。ルールの管理で消して付け直す"
                            % (disp, human(r["cf_rules"])))
            if r["merged"] >= 50:
                hint.append("%s の結合が %s か所ある。結合をやめると軽くなる"
                            % (disp, human(r["merged"])))

        if media:
            mk = sum(i.file_size for i in media) // 1024
            print("")
            print("  画像 %d 個 %s KB" % (len(media), human(mk)))
        if hint:
            print("")
            print("  直すところ")
            for h in dict.fromkeys(hint):
                print("    " + h)
    return 0


if __name__ == "__main__":
    raise SystemExit(main(sys.argv))
