#!/usr/bin/env python3
"""kalina-pdf — Kalina CI auf Dokumente anwenden (bauen, stempeln, pruefen).

Drei Unterbefehle, einer fuer jeden Weg, auf dem ein PDF entsteht:

    kalina-pdf build bericht.html            -> bericht.pdf (weasyprint + CI-CSS)
    kalina-pdf build bericht.html --stamp    -> zusaetzlich Marke/Deckblatt stempeln
    kalina-pdf stamp fremd.pdf               -> fremd-kalina.pdf (Original unberuehrt)
    kalina-pdf check bericht.pdf             -> Seiten, Fuellgrad, Seitenzahl je Seite

Warum ein eigenes Werkzeug: die CI besteht aus CSS, Vorlagen und einem Stempel-Skript,
und jeder Aufrufer soll nicht selbst herausfinden muessen, welche Reihenfolge stimmt
(Layout mit weasyprint, Marke/Wortmarke danach stempeln - reine CSS-Zeichnung der Marke
ist Handarbeit und geht schief).

Rueckgabewerte: 0 = in Ordnung, 1 = Pruefung fehlgeschlagen, 2 = Aufruffehler.
"""
from __future__ import annotations

import argparse
import json
import os
import re
import shutil
import subprocess
import sys
from pathlib import Path

HIER = Path(__file__).resolve().parent.parent      # Repo-Wurzel
CSS = HIER / "css" / "kalina-ci.css"
STEMPEL = HIER / "scripts" / "stamp_existing_pdf.py"
TOKENS = HIER / "tokens.json"

# Kopf- und Fusszone werden bei der Fuellgradmessung ausgespart, sonst meldet jede
# Seite denselben Wert (die Fusszeile ist immer der unterste Block).
KOPFZONE_PT = 60
FUSSZONE_PT = 800


def fehler(text: str, code: int = 2) -> "NoReturn":  # type: ignore[name-defined]
    print(f"kalina-pdf: {text}", file=sys.stderr)
    raise SystemExit(code)


# ---------------------------------------------------------------- build

def cmd_build(args) -> int:
    quelle = Path(args.input).expanduser().resolve()
    if not quelle.is_file():
        fehler(f"HTML nicht gefunden: {quelle}")
    ziel = Path(args.output).expanduser().resolve() if args.output else quelle.with_suffix(".pdf")

    # Vorlage holt ihr CSS relativ zur HTML-Datei. Fehlt es dort, wird die kanonische
    # Datei aus dem Repo daneben kopiert - sonst rendert weasyprint ungestylt und
    # niemand merkt es, weil die Seite trotzdem entsteht.
    css_lokal = quelle.parent / "kalina-ci.css"
    if not css_lokal.exists() and CSS.exists():
        shutil.copyfile(CSS, css_lokal)
        print(f"  CSS ergaenzt: {css_lokal}")

    kopf = re.search(r"<head[^>]*>.*?</head>", quelle.read_text(encoding="utf-8", errors="replace"),
                     re.S | re.I)
    if kopf and "kalina-ci.css" not in kopf.group(0):
        print("  Warnung: <head> verlinkt kalina-ci.css nicht - Layout wird generisch.")

    weasy = shutil.which("weasyprint")
    if weasy:
        lauf = subprocess.run([weasy, str(quelle), str(ziel)], capture_output=True, text=True)
        if lauf.returncode != 0:
            fehler(f"weasyprint fehlgeschlagen:\n{lauf.stderr[-1500:]}", 1)
    else:
        try:
            from weasyprint import HTML  # type: ignore
        except ImportError:
            fehler("weasyprint fehlt (weder Kommando noch Python-Modul)")
        HTML(filename=str(quelle)).write_pdf(str(ziel))

    print(f"  PDF: {ziel} ({ziel.stat().st_size // 1024} KB)")
    if args.stamp:
        gestempelt = ziel.with_name(ziel.stem + "-kalina.pdf")
        rc = _stempeln(ziel, gestempelt, args.titel or quelle.stem, args.fuss, ohne_deckblatt=False)
        if rc != 0:
            return rc
        ziel = gestempelt
        print(f"  gestempelt: {ziel}")
    return cmd_check(argparse.Namespace(**{**vars(args), "pdf": str(ziel),
                                           "min_fill": args.min_fill,
                                           "raster": args.raster}))


# ---------------------------------------------------------------- stamp

def _stempeln(eingabe: Path, ausgabe: Path, titel: str, fuss: str | None,
              ohne_deckblatt: bool) -> int:
    if not STEMPEL.exists():
        fehler(f"Stempel-Skript fehlt: {STEMPEL}")
    cmd = [sys.executable, str(STEMPEL), str(eingabe), str(ausgabe), "--titel", titel]
    if ohne_deckblatt:
        cmd.append("--ohne-deckblatt")
    if fuss:
        cmd += ["--fuss", fuss]
    lauf = subprocess.run(cmd, capture_output=True, text=True)
    if lauf.returncode != 0:
        print(lauf.stdout[-800:], lauf.stderr[-1500:], sep="\n", file=sys.stderr)
        return 1
    return 0


def cmd_stamp(args) -> int:
    quelle = Path(args.pdf).expanduser().resolve()
    if not quelle.is_file():
        fehler(f"PDF nicht gefunden: {quelle}")
    ziel = (Path(args.output).expanduser().resolve() if args.output
            else quelle.with_name(quelle.stem + "-kalina.pdf"))
    if ziel == quelle:
        fehler("Ausgabe darf nicht die Eingabe sein - das Original bleibt unangetastet")
    titel = args.titel or quelle.stem.replace("-", " ").replace("_", " ")
    rc = _stempeln(quelle, ziel, titel, args.fuss, args.ohne_deckblatt)
    if rc:
        return rc
    print(f"  gestempelt: {ziel}")
    return cmd_check(argparse.Namespace(**{**vars(args), "pdf": str(ziel),
                                           "min_fill": args.min_fill, "raster": args.raster}))


# ---------------------------------------------------------------- check

def _fuellgrad(seite) -> float:
    """Anteil der Seitenflaeche, den Text- und Bildbloecke in der Inhaltszone belegen.

    Nur Bloecke zwischen Kopf- und Fusszone zaehlen. Bilder muessen mit: ein Bericht
    mit Zeichnungen liegt nach Textbloecken allein bei 20-37 % und ist trotzdem dicht.
    """
    hoehe = seite.rect.height
    flaeche = 0.0
    for block in seite.get_text("blocks"):
        x0, y0, x1, y1 = block[:4]
        if y0 > KOPFZONE_PT and y1 < FUSSZONE_PT:
            flaeche += max(0.0, (x1 - x0)) * max(0.0, (y1 - y0))
    try:
        for info in seite.get_image_info():
            x0, y0, x1, y1 = info["bbox"]
            if y0 > KOPFZONE_PT and y1 < FUSSZONE_PT:
                flaeche += max(0.0, (x1 - x0)) * max(0.0, (y1 - y0))
    except Exception:
        pass
    nutz = max(1.0, (seite.rect.width) * min(hoehe, FUSSZONE_PT - KOPFZONE_PT))
    return min(1.0, flaeche / nutz)


def cmd_check(args) -> int:
    pfad = Path(args.pdf).expanduser().resolve()
    if not pfad.is_file():
        fehler(f"PDF nicht gefunden: {pfad}")
    try:
        import pymupdf  # type: ignore
    except ImportError:
        try:
            import fitz as pymupdf  # type: ignore
        except ImportError:
            fehler("pymupdf fehlt - Fuellgrad nicht pruefbar")
    dok = pymupdf.open(pfad)
    if dok.page_count == 0:
        fehler("PDF hat keine Seiten", 1)

    # Zwei Schwellen, zwei Bedeutungen: unter `minimum` ist die Seite ein
    # Layoutfehler (Fast-Leerseite durch Umbruch), unter `ziel` ist sie nur duenn.
    # Die 55 % aus der Erfahrung gelten fuer dichte Berichte; reiner Fliesstext
    # erreicht sie selten und ist trotzdem richtig gesetzt.
    minimum = args.min_fill
    ziel = args.ziel
    deckblatt = getattr(args, "deckblatt", True)
    schwach, duenn = [], []
    print(f"  Seiten: {dok.page_count}   Groesse: {pfad.stat().st_size // 1024} KB")
    for i, seite in enumerate(dok, start=1):
        f = _fuellgrad(seite)
        worte = len(seite.get_text("words"))
        # Ein Deckblatt ist absichtlich duenn: Titel, Marke, Fusszeile. Es wird
        # berichtet, aber nicht bewertet - sonst schlaegt jede gestempelte Datei fehl.
        geschuetzt = deckblatt and i == 1
        if geschuetzt:
            marke = "  (Deckblatt, nicht bewertet)"
        elif f < minimum:
            marke = "  <- FEHLER: Fast-Leerseite"
        elif f < ziel:
            marke = "  (duenn, unter Ziel)"
        else:
            marke = ""
        print(f"    Seite {i:3}: Fuellgrad {f * 100:5.1f} %   {worte:5} Woerter{marke}")
        if geschuetzt:
            if args.raster and i == 1:      # Deckblatt-Raster vor dem continue
                bild = Path(args.raster).expanduser().resolve()
                bild.mkdir(parents=True, exist_ok=True)
                pix = seite.get_pixmap(dpi=150)
                pix.save(str(bild / (pfad.stem + "-s1.png")))
                print(f"    Raster: {bild / (pfad.stem + '-s1.png')}")
            continue
        if f < minimum:
            schwach.append(i)
        elif f < ziel:
            duenn.append(i)
        if args.raster and i == 1:
            bild = Path(args.raster).expanduser().resolve()
            bild.mkdir(parents=True, exist_ok=True)
            ziel = bild / (pfad.stem + "-s1.png")
            pix = seite.get_pixmap(dpi=150)
            pix.save(str(ziel))
            print(f"    Raster: {ziel}")
    dok.close()
    if schwach:
        print(f"  FEHLGESCHLAGEN: Fast-Leerseite(n) unter {minimum * 100:.0f} %: {schwach}")
        return 1
    hinweis = f"  duenn (unter {ziel * 100:.0f} %): {duenn}" if duenn else ""
    print(f"  OK: keine Fast-Leerseite (Minimum {minimum * 100:.0f} %){hinweis}")
    return 0


# ---------------------------------------------------------------- tokens

def cmd_tokens(args) -> int:
    if not TOKENS.exists():
        fehler(f"tokens.json fehlt: {TOKENS}")
    daten = json.loads(TOKENS.read_text(encoding="utf-8"))
    if args.feld:
        wert = daten
        for teil in args.feld.split("."):
            if not isinstance(wert, dict) or teil not in wert:
                fehler(f"Feld nicht vorhanden: {args.feld}")
            wert = wert[teil]
        print(wert)
    else:
        print(json.dumps(daten, indent=2, ensure_ascii=False))
    return 0


# ---------------------------------------------------------------- main

def main() -> int:
    p = argparse.ArgumentParser(prog="kalina-pdf", description=__doc__.splitlines()[0])
    sub = p.add_subparsers(dest="cmd", required=True)

    def pruef_args(sp):
        sp.add_argument("--min-fill", type=float, default=0.10,
                        help="Untergrenze je Seite; darunter gilt die Seite als Layoutfehler (0.10)")
        sp.add_argument("--ziel", type=float, default=0.55,
                        help="Zielwert fuer dichte Seiten (0.55) - unterschritten wird nur berichtet")
        sp.add_argument("--raster", metavar="DIR", help="Seite 1 als PNG dorthin schreiben")
        sp.add_argument("--kein-deckblatt-bonus", dest="deckblatt", action="store_false",
                        help="Seite 1 mitbewerten (Standard: Deckblatt wird nur berichtet)")

    b = sub.add_parser("build", help="HTML mit CI-CSS zu PDF")
    b.add_argument("input")
    b.add_argument("-o", "--output")
    b.add_argument("--stamp", action="store_true", help="Deckblatt/Marke zusaetzlich stempeln")
    b.add_argument("--titel", help="Titel fuer das Deckblatt")
    b.add_argument("--fuss", help="Fusstext")
    pruef_args(b)
    b.set_defaults(func=cmd_build)

    s = sub.add_parser("stamp", help="bestehendes PDF mit CI versehen (Original bleibt)")
    s.add_argument("pdf")
    s.add_argument("-o", "--output")
    s.add_argument("--titel")
    s.add_argument("--fuss")
    s.add_argument("--ohne-deckblatt", action="store_true")
    pruef_args(s)
    s.set_defaults(func=cmd_stamp)

    c = sub.add_parser("check", help="Seiten, Fuellgrad, Seitenzahl pruefen")
    c.add_argument("pdf")
    pruef_args(c)
    c.set_defaults(func=cmd_check)

    t = sub.add_parser("tokens", help="CI-Token (Farben, Geometrie) ausgeben")
    t.add_argument("feld", nargs="?")
    t.set_defaults(func=cmd_tokens)

    args = p.parse_args()
    for name, wert in (("min_fill", 0.10), ("ziel", 0.55), ("deckblatt", True)):
        if not hasattr(args, name):
            setattr(args, name, wert)
    return args.func(args)


if __name__ == "__main__":
    raise SystemExit(main())
