"""
integrity-scanner — CLI entry point.

Usage:
    python -m scanner <URL> <subcommand> [options]
    python -m scanner --config <site>/config.yaml <subcommand> [options]

Subcommands:
    init      First-time crawl + baseline setup workflow
    crawl     Crawl site, save snapshot (no comparison)
    check     Compare latest snapshot against baseline, report + alert
    scan      crawl + check in one step (use this for cron)
    approve   Approve snapshot as new baseline
    report    Display / export latest report
    status    Show baseline age, last scan, open changes
"""
import argparse
import json
import logging
import re
import shutil
import sys
from datetime import datetime, timezone
from pathlib import Path
from urllib.parse import urlparse

from .ai_analyzer import run_ai_analysis, score_ai_findings
from .alerter import send_alert
from .baseline import BaselineManager
from .checker import (
    check_external_links,
    check_links_against_whitelist,
    check_security_headers,
    fetch_asset_hashes,
    is_suspicious_url,
    load_whitelist,
)
from .config import load_config, resolve_paths
from .crawler import Crawler
from .differ import (
    compare_asset_hashes,
    compare_cloaking,
    compare_snapshots,
    score_asset_diff,
    score_cloak_diff,
    score_diff,
)
from .extractor import extract_page
from .plain import ampel, ampel_zeile, klartext_befunde, scanner_cmd, was_tun
from .report import render_markdown

# Reihenfolge der Risiko-Level, um das schlechteste zu bestimmen
_LEVEL_ORDER = {"green": 0, "yellow": 1, "red": 2}


def _worst_level(levels: list[str]) -> str:
    """Gibt das schwerwiegendste Level aus einer Liste zurück (green<yellow<red)."""
    worst = "green"
    for lvl in levels:
        if _LEVEL_ORDER.get(lvl, 0) > _LEVEL_ORDER.get(worst, 0):
            worst = lvl
    return worst


# ---------------------------------------------------------------------------
# Logging
# ---------------------------------------------------------------------------

GOOGLEBOT_UA = "Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)"


def _setup_logging(logs_dir: str, verbose: bool) -> None:
    level = logging.DEBUG if verbose else logging.INFO
    logs_path = Path(logs_dir)
    logs_path.mkdir(parents=True, exist_ok=True)
    log_file = logs_path / "scanner.log"

    fmt = "%(asctime)s %(levelname)-8s %(name)s: %(message)s"
    handlers: list[logging.Handler] = [
        logging.StreamHandler(sys.stderr),
        logging.FileHandler(log_file, encoding="utf-8"),
    ]
    logging.basicConfig(level=level, format=fmt, handlers=handlers, force=True)


# ---------------------------------------------------------------------------
# Progress display helpers
# ---------------------------------------------------------------------------

def _progress(label: str, done: int, total: int | None = None) -> None:
    """Overwrite the current terminal line with a progress update."""
    if total:
        line = f"  {label}: {done}/{total}"
    else:
        line = f"  {label}: {done}"
    print(f"\r{line:<72}", end="", flush=True)


def _progress_end() -> None:
    """Clear the progress line after a phase completes."""
    print(f"\r{'':<72}\r", end="", flush=True)


# Crawl + extract helpers
# ---------------------------------------------------------------------------

def _crawl_and_extract(cfg: dict) -> tuple[list[dict], list[dict]]:
    """Run crawler, extract each HTML page. Returns (pages, errors)."""
    logger = logging.getLogger(__name__)
    crawler = Crawler(cfg)
    crawler.on_progress = lambda fetched, queued: _progress(
        "Crawling", fetched, fetched + queued if queued else None
    )
    raw = crawler.crawl()
    _progress_end()

    pages: list[dict] = []
    for raw_page in raw["pages"]:
        if raw_page.get("html"):
            extracted = extract_page(raw_page["html"], raw_page["url"], cfg)
            page = {**raw_page, **extracted}
        else:
            page = dict(raw_page)
        pages.append(page)

    logger.info("Extracted %d pages", len(pages))
    return pages, raw["errors"]


# ---------------------------------------------------------------------------
# Whitelist loading
# ---------------------------------------------------------------------------

def _load_allowed_external(cfg: dict) -> set[str]:
    config_dir = Path(cfg["config_dir"])
    return load_whitelist(config_dir / "allowed_external.yaml")


# ---------------------------------------------------------------------------
# Report building
# ---------------------------------------------------------------------------

def _build_report(
    target: str,
    diff: dict,
    assessment: dict,
    whitelist_violations: list[dict],
    missing_sec_headers: dict,
    crawl_errors: list[dict],
    snap_dir: Path,
    cloak_diff: dict | None = None,
    asset_diff: dict | None = None,
    ext_links: dict | None = None,
    ai_result: dict | None = None,
    config_path: str | None = None,
) -> dict:
    report = {
        "generated_at": datetime.now(timezone.utc).isoformat(),
        "target": target,
        "snapshot_dir": str(snap_dir),
        "config_path": config_path,
        "assessment": assessment,
        "diff": diff,
        "whitelist_violations": whitelist_violations,
        "missing_security_headers": missing_sec_headers,
        "crawl_errors": crawl_errors,
    }
    # Optionale Ergebnisse der wöchentlichen Zusatzprüfungen (nur wenn gelaufen)
    if cloak_diff is not None:
        report["cloak_diff"] = cloak_diff
    if asset_diff is not None:
        report["asset_diff"] = asset_diff
    if ext_links is not None:
        report["ext_links"] = ext_links
    if ai_result is not None:
        report["ai_result"] = ai_result
    return report


def _write_report(report: dict, reports_dir: str) -> tuple[Path, Path]:
    ts = datetime.now(timezone.utc).strftime("%Y%m%d_%H%M%S")
    out = Path(reports_dir) / ts
    out.mkdir(parents=True, exist_ok=True)

    json_path = out / "report.json"
    md_path = out / "report.md"

    json_path.write_text(
        json.dumps(report, indent=2, ensure_ascii=False), encoding="utf-8"
    )
    md_path.write_text(render_markdown(report), encoding="utf-8")
    return json_path, md_path



def _write_cloak_report(report: dict, reports_dir: str) -> tuple[Path, Path]:
    ts = datetime.now(timezone.utc).strftime("%Y%m%d_%H%M%S")
    out = Path(reports_dir) / f"{ts}_cloak"
    out.mkdir(parents=True, exist_ok=True)
    json_path = out / "report.json"
    md_path = out / "report.md"
    json_path.write_text(json.dumps(report, indent=2, ensure_ascii=False), encoding="utf-8")
    md_path.write_text(_render_cloak_markdown(report), encoding="utf-8")
    return json_path, md_path


def _render_cloak_markdown(r: dict) -> str:
    a = r.get("assessment", {})
    cd = r.get("cloak_diff", {})
    level = a.get("level", "?").upper()
    score = a.get("score", 0)
    level_badge = {"GREEN": "[OK]", "YELLOW": "[WARNUNG]", "RED": "[ALARM]"}.get(level, level)

    lines = [
        f"# Cloaking-Check — {r.get('target', '?')}",
        "",
        f"**Erstellt:** {r.get('generated_at', '?')}  ",
        f"**Level:** {level_badge}  ",
        f"**Score:** {score}",
        "",
        "## Zusammenfassung",
        "",
        "| Kennzahl | Wert |",
        "|---|---|",
        f"| Geprüfte Seiten | {cd.get('pages_checked', '?')} |",
        f"| Seiten mit Unterschieden | {len(cd.get('findings', []))} |",
        "",
        "## Risikobewertung",
        "",
    ]
    for reason in a.get("reasons", []):
        lines.append(f"- {reason}")
    if not a.get("reasons"):
        lines.append("- Keine Cloaking-Anzeichen gefunden.")

    if cd.get("findings"):
        lines += ["", "## Auffällige Seiten", ""]
        for page in cd["findings"]:
            lines += [f"### {page['url']}", ""]
            if page.get("vary_ua"):
                lines.append("- `Vary: User-Agent` Header gesetzt")
            if page.get("extra_links"):
                lines.append(f"- Bot-Only-Links ({len(page['extra_links'])}):")
                for link in page["extra_links"]:
                    lines.append(f"  - `{link}`")
            if page.get("extra_text_chars", 0) > 50:
                lines.append(f"- Bot sieht +{page['extra_text_chars']} Zeichen mehr Text")
            if page.get("text_diff"):
                lines += ["", "```diff"]
                lines += page["text_diff"][:20]
                if len(page["text_diff"]) > 20:
                    lines.append(f"... ({len(page['text_diff']) - 20} weitere Zeilen)")
                lines.append("```")
            lines.append("")

    lines += ["", "---", "", "## Nächste Schritte", ""]
    lev = a.get("level", "green")
    if lev == "red":
        lines += [
            "1. **Sofort**: Seiten manuell vergleichen: `curl -A 'Googlebot' <URL> | grep -i hidden`",
            "2. Server-Logs auf Bot-IP-Ranges prüfen.",
            "3. CMS-Plugins / Theme-Dateien auf injizierte Cloaking-Logik untersuchen.",
            "4. Nach Bereinigung: `python -m scanner cloak-check` erneut ausführen.",
        ]
    elif lev == "yellow":
        lines += [
            "1. Markierte Seiten prüfen: `curl -s -A 'Googlebot' <URL> > /tmp/bot.html && diff <(curl -s <URL>) /tmp/bot.html`",
            "2. Bei bestätigtem Cloaking: Hoster kontaktieren.",
        ]
    else:
        lines += ["Keine Aktion erforderlich."]

    return "\n".join(lines) + "\n"


def _print_cloak_summary(assessment: dict, cloak_diff: dict) -> None:
    level = assessment.get("level", "?").upper()
    score = assessment.get("score", 0)
    print()
    print(f"=== Cloaking-Check: {level} (Score {score}) ===")
    for reason in assessment.get("reasons", []):
        print(f"  * {reason}")
    if not assessment.get("reasons"):
        print("  Keine Cloaking-Anzeichen gefunden.")
    print(f"  Geprüfte Seiten:      {cloak_diff.get('pages_checked', 0)}")
    print(f"  Seiten mit Unterschied: {len(cloak_diff.get('findings', []))}")


def _write_ext_links_report(report: dict, reports_dir: str) -> tuple[Path, Path]:
    ts = datetime.now(timezone.utc).strftime("%Y%m%d_%H%M%S")
    out = Path(reports_dir) / f"{ts}_ext-links"
    out.mkdir(parents=True, exist_ok=True)
    json_path = out / "report.json"
    md_path = out / "report.md"
    json_path.write_text(json.dumps(report, indent=2, ensure_ascii=False), encoding="utf-8")
    md_path.write_text(_render_ext_links_markdown(report), encoding="utf-8")
    return json_path, md_path


def _render_ext_links_markdown(r: dict) -> str:
    lines = [
        f"# Externe Links — {r.get('target', '?')}",
        "",
        f"**Erstellt:** {r.get('generated_at', '?')}",
        "",
        "## Zusammenfassung",
        "",
        "| Kennzahl | Wert |",
        "|---|---|",
        f"| Geprüfte Links | {r.get('total', 0)} |",
        f"| Erreichbar (2xx/3xx) | {r.get('ok', 0)} |",
        f"| Kaputt (4xx/5xx) | {r.get('broken', 0)} |",
        f"| Nicht erreichbar (Fehler) | {r.get('errors', 0)} |",
        "",
    ]

    broken = r.get("broken_links", [])
    if broken:
        lines += ["## Kaputte externe Links", ""]
        for entry in broken:
            lines.append(f"### HTTP {entry['status']} — `{entry['url']}`")
            lines.append("")
            lines.append("Gefunden auf:")
            for page in entry["pages"]:
                lines.append(f"- {page}")
            lines.append("")

    errors = r.get("error_links", [])
    if errors:
        lines += ["## Nicht erreichbar (Verbindungsfehler)", ""]
        for entry in errors:
            lines.append(f"- `{entry['url']}` — {entry['error']}")
            for page in entry["pages"]:
                lines.append(f"  - verlinkt von: {page}")
        lines.append("")

    if not broken and not errors:
        lines += ["## Ergebnis", "", "Alle externen Links sind erreichbar."]

    return "\n".join(lines) + "\n"


def _print_ext_links_summary(report: dict) -> None:
    print()
    print(f"=== Externe Links: {report['total']} geprüft ===")
    print(f"  Erreichbar:          {report['ok']}")
    print(f"  Kaputt (4xx/5xx):    {report['broken']}")
    print(f"  Verbindungsfehler:   {report['errors']}")
    if report.get("broken_links"):
        print()
        for entry in report["broken_links"]:
            print(f"  [HTTP {entry['status']}] {entry['url']}")
            for page in entry["pages"][:3]:
                print(f"           → {page}")


def _collect_asset_urls(snap: dict) -> dict[str, str]:
    """Return {url: type} for all asset links across all pages in a snapshot."""
    assets: dict[str, str] = {}
    for page in snap["pages"].values():
        if page.get("status") != 200:
            continue
        for ltype in ("script", "link_rel", "img"):
            for link in page.get("links", {}).get(ltype, []):
                if isinstance(link, dict):
                    url = link.get("url", "")
                    if url and url not in assets:
                        assets[url] = ltype
    return assets


def _write_asset_report(report: dict, reports_dir: str) -> tuple[Path, Path]:
    ts = datetime.now(timezone.utc).strftime("%Y%m%d_%H%M%S")
    out = Path(reports_dir) / f"{ts}_assets"
    out.mkdir(parents=True, exist_ok=True)
    json_path = out / "report.json"
    md_path = out / "report.md"
    json_path.write_text(json.dumps(report, indent=2, ensure_ascii=False), encoding="utf-8")
    md_path.write_text(_render_asset_markdown(report), encoding="utf-8")
    return json_path, md_path


def _render_asset_markdown(r: dict) -> str:
    a = r.get("assessment", {})
    d = r.get("asset_diff", {})
    level = a.get("level", "?").upper()
    level_badge = {"GREEN": "[OK]", "YELLOW": "[WARNUNG]", "RED": "[ALARM]"}.get(level, level)

    lines = [
        f"# Asset-Check — {r.get('target', '?')}",
        "",
        f"**Erstellt:** {r.get('generated_at', '?')}  ",
        f"**Level:** {level_badge}  ",
        f"**Score:** {a.get('score', 0)}",
        "",
        "## Zusammenfassung",
        "",
        "| Kennzahl | Wert |",
        "|---|---|",
        f"| Geprüfte Assets | {d.get('total_checked', 0)} |",
        f"| Geändert | {len(d.get('changed', []))} |",
        f"| Neu (nicht in Baseline) | {len(d.get('new', []))} |",
        f"| Fehlt (war in Baseline) | {len(d.get('missing', []))} |",
        f"| Abruffehler | {len(d.get('fetch_errors', []))} |",
        "",
        "## Risikobewertung",
        "",
    ]
    for reason in a.get("reasons", []):
        lines.append(f"- {reason}")
    if not a.get("reasons"):
        lines.append("- Keine Änderungen in Binärdateien gefunden.")

    if d.get("changed"):
        lines += ["", "## Geänderte Dateien", ""]
        for entry in d["changed"]:
            size_info = f" ({entry['size_diff']:+d} Bytes)" if entry.get("size_diff") else ""
            lines.append(f"- **[{entry['type']}]** `{entry['url']}`{size_info}")
            lines.append(f"  - vorher: `{entry['old_sha256']}`")
            lines.append(f"  - jetzt:  `{entry['new_sha256']}`")

    if d.get("new"):
        lines += ["", "## Neue Assets (nicht in Baseline)", ""]
        for url in d["new"]:
            lines.append(f"- `{url}`")

    if d.get("missing"):
        lines += ["", "## Fehlende Assets (waren in Baseline)", ""]
        for url in d["missing"]:
            lines.append(f"- `{url}`")

    if d.get("fetch_errors"):
        lines += ["", "## Abruffehler", ""]
        for entry in d["fetch_errors"]:
            lines.append(f"- `{entry['url']}`: {entry['error']}")

    lines += ["", "---", "", "## Nächste Schritte", ""]
    lev = a.get("level", "green")
    if lev in ("red", "yellow"):
        lines += [
            "1. Geänderte Dateien mit Backup vergleichen.",
            "2. Bei unbekannten Änderungen: Hoster kontaktieren.",
            "3. Nach Prüfung freigeben: `python -m scanner approve-assets`",
        ]
    else:
        lines += ["Keine Aktion erforderlich."]

    return "\n".join(lines) + "\n"


def _print_asset_summary(assessment: dict, asset_diff: dict) -> None:
    level = assessment.get("level", "?").upper()
    score = assessment.get("score", 0)
    print()
    print(f"=== Asset-Check: {level} (Score {score}) ===")
    for reason in assessment.get("reasons", []):
        print(f"  * {reason}")
    if not assessment.get("reasons"):
        print("  Keine Änderungen.")
    print(f"  Geprüfte Assets:  {asset_diff.get('total_checked', 0)}")
    print(f"  Geändert:         {len(asset_diff.get('changed', []))}")
    print(f"  Neu:              {len(asset_diff.get('new', []))}")
    print(f"  Fehlt:            {len(asset_diff.get('missing', []))}")


def _latest_report_path(reports_dir: str) -> Path | None:
    rd = Path(reports_dir)
    if not rd.exists():
        return None
    dirs = sorted(d for d in rd.iterdir() if d.is_dir())
    if not dirs:
        return None
    return dirs[-1] / "report.md"


# ---------------------------------------------------------------------------
# Subcommands
# ---------------------------------------------------------------------------

def cmd_init(args: argparse.Namespace, cfg: dict) -> int:
    logger = logging.getLogger("scanner.init")
    print("=== Initialisierung: Erster Crawl ===")
    print(f"Ziel: {cfg['target']}")

    pages, errors = _crawl_and_extract(cfg)
    if not pages:
        print("FEHLER: Kein Seiten gecrawlt.", file=sys.stderr)
        return 1

    bm = BaselineManager(cfg["data_dir"])
    snap_dir = bm.save_snapshot(pages, errors)
    print(f"Snapshot gespeichert: {snap_dir}")
    print(f"  Seiten:  {len(pages)}")
    print(f"  Fehler:  {len(errors)}")
    print()

    allowed_ext = _load_allowed_external(cfg)

    print("=== Gefundene interne URLs ===")
    for p in pages:
        print(f"  {p['url']}")

    print()
    print("=== Externe Links (alle) ===")
    seen_domains: set[str] = set()
    for p in pages:
        for ltype, links in p.get("links", {}).items():
            if not isinstance(links, list):
                continue
            for l in links:
                if isinstance(l, dict) and l.get("class") == "external":
                    domain = urlparse(l.get("url", "")).netloc
                    if domain and domain not in seen_domains:
                        seen_domains.add(domain)
                        in_wl = " [in Whitelist]" if domain in allowed_ext else " [NICHT in Whitelist]"
                        print(f"  {domain}{in_wl}")

    print()
    auffaelligkeiten = 0
    for p in pages:
        if p.get("hidden_content"):
            print(f"  [!] Hidden Content auf {p['url']}: {len(p['hidden_content'])} Fund(e)")
            auffaelligkeiten += len(p["hidden_content"])
        if p.get("inline_scripts"):
            print(f"  [!] Verdächtige Inline-Scripts auf {p['url']}: {len(p['inline_scripts'])} Fund(e)")
            auffaelligkeiten += len(p["inline_scripts"])
        if p.get("links", {}).get("meta_refresh"):
            print(f"  [!] Meta-Refresh auf {p['url']}")
            auffaelligkeiten += 1
        if p.get("comment_links"):
            print(f"  [!] Links in HTML-Kommentaren auf {p['url']}")
            auffaelligkeiten += len(p["comment_links"])

    if errors:
        print()
        print("=== Crawl-Fehler ===")
        for e in errors:
            print(f"  {e['url']}: {e['error']}")

    print()
    if auffaelligkeiten:
        print(f"[WARNUNG] {auffaelligkeiten} potenzielle Auffälligkeit(en) gefunden.")
        print("  Bitte alle URLs oben manuell prüfen, bevor Sie freigeben.")
    else:
        print("[OK] Keine offensichtlichen Auffälligkeiten gefunden.")

    print()
    print("=== Naechste Schritte ===")
    print("  1. Prüfen Sie alle oben aufgelisteten externen Links und Auffälligkeiten.")
    print("  2. Wenn alles sauber ist, Baseline freigeben:")
    print("       python -m scanner approve --all")
    print("  3. Danach ist der tägliche Betrieb möglich:")
    print("       python -m scanner scan")
    return 0


def cmd_crawl(args: argparse.Namespace, cfg: dict) -> int:
    logger = logging.getLogger("scanner.crawl")
    print(f"Crawle {cfg['target']} ...")
    pages, errors = _crawl_and_extract(cfg)
    bm = BaselineManager(cfg["data_dir"])
    snap_dir = bm.save_snapshot(pages, errors)
    print(f"Snapshot: {snap_dir}")
    print(f"  Seiten:  {len(pages)}")
    print(f"  Fehler:  {len(errors)}")
    return 0


def _run_content_check(cfg: dict, snap: dict, baseline: dict) -> tuple[dict, dict, list, dict]:
    """
    Kern der Inhaltsprüfung: vergleicht Snapshot mit Baseline, prüft Whitelist,
    Security-Header und verdächtige Dateinamen. Schreibt/alarmiert NICHT.
    Returns (diff, assessment, whitelist_violations, missing_security_headers).
    """
    diff = compare_snapshots(baseline["pages"], snap["pages"], cfg)

    # Whitelist / header / webshell checks — only on successfully fetched HTML
    # pages (status 200). 404 / asset responses would otherwise create noise.
    allowed_ext = _load_allowed_external(cfg)
    base_netloc = urlparse(cfg["target"]).netloc
    wl_violations: list[dict] = []
    missing_sec: dict = {}
    suspicious_filenames: list[dict] = []

    for page in snap["pages"].values():
        if page.get("status") != 200 or not page.get("links"):
            continue

        violations = check_links_against_whitelist(page, allowed_ext, base_netloc)
        if violations:
            wl_violations.extend({"page": page["url"], **v} for v in violations)

        hdrs = page.get("response_headers", {})
        missing = check_security_headers(hdrs, cfg.get("security_headers", []))
        if missing:
            missing_sec[page["url"]] = missing

        for ltype, links in page.get("links", {}).items():
            if not isinstance(links, list):
                continue
            for l in links:
                if isinstance(l, dict) and is_suspicious_url(l.get("url", "")):
                    suspicious_filenames.append({
                        "page": page["url"],
                        "url": l["url"],
                        "link_type": ltype,
                    })

    diff["suspicious_filenames"] = suspicious_filenames

    for v in wl_violations:
        if v.get("type") == "unlisted_external_domain":
            existing = diff.get("new_external_domains", [])
            domain = v.get("domain", "")
            if domain and domain not in existing:
                existing.append(domain)
                diff["new_external_domains"] = existing

    assessment = score_diff(diff, cfg)
    return diff, assessment, wl_violations, missing_sec


def _run_cloak_core(cfg: dict, pages_normal: list | None = None) -> tuple[dict, dict]:
    """Doppel-Crawl Browser-UA vs. Googlebot-UA. Returns (cloak_diff, assessment).

    Wenn pages_normal übergeben wird (z.B. aus cmd_scan), wird der Browser-UA-Crawl
    übersprungen und diese Seiten direkt verwendet.
    """
    if pages_normal is None:
        pages_normal, _ = _crawl_and_extract(cfg)
    bot_cfg = {**cfg, "user_agent": GOOGLEBOT_UA}
    pages_bot, _ = _crawl_and_extract(bot_cfg)
    normal_map = {p["url"]: p for p in pages_normal}
    bot_map = {p["url"]: p for p in pages_bot}
    cloak_diff = compare_cloaking(normal_map, bot_map, cfg)
    return cloak_diff, score_cloak_diff(cloak_diff, cfg)


def _run_ext_links_core(cfg: dict, snap: dict) -> dict:
    """Prüft externe <a>-Links auf Erreichbarkeit. Returns ext_links-Report-Dict."""
    ext_urls: dict[str, list[str]] = {}
    for page in snap["pages"].values():
        if page.get("status") != 200:
            continue
        for link in page.get("links", {}).get("a", []):
            if isinstance(link, dict) and link.get("class") == "external":
                url = link.get("url", "")
                if url:
                    ext_urls.setdefault(url, []).append(page["url"])

    if not ext_urls:
        return {"total": 0, "ok": 0, "broken": 0, "errors": 0,
                "broken_links": [], "error_links": []}

    total_ext = len(ext_urls)
    results = check_external_links(
        list(ext_urls.keys()),
        timeout=cfg.get("request_timeout", 10),
        on_progress=lambda done, total: _progress("Externe Links prüfen", done, total),
    )
    _progress_end()
    broken = {url: r for url, r in results.items() if r.get("status") and r["status"] >= 400}
    conn_errors = {url: r for url, r in results.items() if r.get("error")}
    return {
        "total": len(results),
        "ok": len(results) - len(broken) - len(conn_errors),
        "broken": len(broken),
        "errors": len(conn_errors),
        "broken_links": [
            {"url": url, "status": r["status"], "pages": ext_urls[url]}
            for url, r in sorted(broken.items())
        ],
        "error_links": [
            {"url": url, "error": r["error"], "pages": ext_urls[url]}
            for url, r in sorted(conn_errors.items())
        ],
    }


def _run_assets_core(cfg: dict, bm: BaselineManager, snap: dict) -> tuple[dict, dict, bool]:
    """Hasht Assets und vergleicht mit Baseline. Returns (asset_diff, assessment, has_baseline)."""
    asset_urls = _collect_asset_urls(snap)
    if not asset_urls:
        empty = {"changed": [], "new": [], "missing": [], "fetch_errors": [], "total_checked": 0}
        return empty, score_asset_diff(empty, cfg), bool(bm.load_asset_hashes())

    raw_hashes = fetch_asset_hashes(list(asset_urls.keys()), timeout=cfg.get("request_timeout", 15))
    current_hashes = {url: {**r, "type": asset_urls[url]} for url, r in raw_hashes.items()}
    baseline_hashes = bm.load_asset_hashes()
    asset_diff = compare_asset_hashes(baseline_hashes, current_hashes)
    return asset_diff, score_asset_diff(asset_diff, cfg), bool(baseline_hashes)


def _invalidate_ai_for_changed_assets(bm: BaselineManager, asset_diff: dict) -> None:
    """Geänderte Bild-Assets (byte-getauscht) aus der KI-url_hashes-Erinnerung entfernen,
    damit die KI sie neu lädt UND inhaltlich neu bewertet. Nicht-Bild-URLs sind nicht in
    url_hashes → werden ignoriert."""
    changed_urls = [c["url"] for c in (asset_diff or {}).get("changed", []) if c.get("url")]
    n = bm.invalidate_ai_url_hashes(changed_urls)
    if n:
        logging.getLogger("scanner.assets").info(
            "KI-Cache für %d geänderte Bild(er) invalidiert → inhaltliche Neubewertung.", n)


def cmd_check(args: argparse.Namespace, cfg: dict) -> int:
    logger = logging.getLogger("scanner.check")
    bm = BaselineManager(cfg["data_dir"])

    if not bm.baseline_exists():
        _print_kein_baseline_hinweis()
        return 1

    snap = bm.load_snapshot()
    if not snap:
        print("Es gibt noch keinen aktuellen Stand zum Vergleichen.")
        print(f"Bitte zuerst ausführen:  {scanner_cmd(cfg['target'], 'scan')}")
        return 1

    baseline = bm.load_baseline()
    diff, assessment, wl_violations, missing_sec = _run_content_check(cfg, snap, baseline)

    # --- KI-Inhaltsanalyse (hash-gegated) ---
    ai_result = None
    if cfg.get("ai_analysis", {}).get("enabled"):
        ai_result = run_ai_analysis(cfg, bm, snap, diff)
        ai_assess = score_ai_findings(ai_result, cfg)
        overall = _worst_level([assessment["level"], ai_assess["level"]])
        assessment = {**assessment, "level": overall, "exit_code": _LEVEL_ORDER[overall],
                      "score": assessment.get("score", 0) + ai_assess.get("score", 0),
                      "reasons": assessment.get("reasons", []) + ai_assess["reasons"]}

    report = _build_report(
        cfg["target"], diff, assessment, wl_violations, missing_sec,
        snap.get("errors", []), snap["dir"], ai_result=ai_result,
        config_path=getattr(args, "config", None),
    )
    _write_report(report, cfg["reports_dir"])
    _print_summary(assessment, diff, ai_result=ai_result, target=cfg["target"],
                   config_path=getattr(args, "config", None))
    send_alert(report, cfg)
    return assessment["exit_code"]


def cmd_scan(args: argparse.Namespace, cfg: dict) -> int:
    """crawl + check. Führt fällige Wochen-Prüfungen automatisch mit aus."""
    logger = logging.getLogger("scanner.scan")
    print(f"Prüfe Ihre Website {cfg['target']} ...")
    pages, errors = _crawl_and_extract(cfg)
    bm = BaselineManager(cfg["data_dir"])
    snap = bm.load_snapshot(bm.save_snapshot(pages, errors))
    logger.info("Snapshot: %d Seiten, %d Fehler", len(pages), len(errors))

    if not bm.baseline_exists():
        print()
        _print_kein_baseline_hinweis()
        return 0

    baseline = bm.load_baseline()
    diff, assessment, wl_violations, missing_sec = _run_content_check(cfg, snap, baseline)
    levels = [assessment["level"]]
    score_total = assessment.get("score", 0)  # alle Teil-Scores aufaddieren

    # --- Fällige Wochen-Prüfungen automatisch mitlaufen lassen ---
    cloak_diff = asset_diff = ext_links = None
    pc = cfg.get("periodic_checks", {})
    if pc.get("enabled", True):
        interval = pc.get("interval_days", 7)
        if pc.get("cloak_check", True) and bm.is_check_due("cloak_check", interval):
            print("  Wöchentliche Tarnungs-Prüfung läuft mit ...")
            cloak_diff, cloak_assess = _run_cloak_core(cfg, pages_normal=pages)
            levels.append(cloak_assess["level"])
            score_total += cloak_assess.get("score", 0)
            bm.mark_check_run("cloak_check")
        if pc.get("assets", True) and bm.is_check_due("assets", interval):
            print("  Wöchentliche Datei-Prüfung läuft mit ...")
            asset_diff, asset_assess, _ = _run_assets_core(cfg, bm, snap)
            levels.append(asset_assess["level"])
            score_total += asset_assess.get("score", 0)
            bm.mark_check_run("assets")
            # Geänderte Bilder → KI-Cache invalidieren, damit die KI sie inhaltlich neu
            # bewertet (läuft hier vor run_ai_analysis → Neubewertung noch im selben Scan).
            _invalidate_ai_for_changed_assets(bm, asset_diff)
        if pc.get("ext_links", True) and bm.is_check_due("ext_links", interval):
            print("  Wöchentliche Prüfung externer Links läuft mit ...")
            ext_links = _run_ext_links_core(cfg, snap)  # informativ, hebt Level nicht an
            bm.mark_check_run("ext_links")

    # --- KI-Inhaltsanalyse (hash-gegated, läuft bei jedem Scan; meist 0 API-Calls) ---
    ai_result = None
    if cfg.get("ai_analysis", {}).get("enabled"):
        ai_result = run_ai_analysis(cfg, bm, snap, diff)
        if ai_result.get("api_calls"):
            print(f"  KI-Inhaltsanalyse: {ai_result['api_calls']} neue Abfrage(n), "
                  f"{ai_result['cache_hits']} aus Cache.")
        ai_assess = score_ai_findings(ai_result, cfg)
        levels.append(ai_assess["level"])
        score_total += ai_assess.get("score", 0)
        assessment["reasons"] = assessment.get("reasons", []) + ai_assess["reasons"]

    # Gesamt-Level = schlechtestes aus Inhalt + Cloaking + Datei-Check + KI;
    # Gesamt-Score = Summe aller Teil-Scores (damit Level und Score zusammenpassen).
    overall = _worst_level(levels)
    assessment = {**assessment, "level": overall, "score": score_total,
                  "exit_code": _LEVEL_ORDER[overall]}

    report = _build_report(
        cfg["target"], diff, assessment, wl_violations, missing_sec,
        snap.get("errors", []), snap["dir"],
        cloak_diff=cloak_diff, asset_diff=asset_diff, ext_links=ext_links,
        ai_result=ai_result, config_path=getattr(args, "config", None),
    )
    _write_report(report, cfg["reports_dir"])
    _print_summary(assessment, diff, cloak_diff, asset_diff, ext_links, ai_result, cfg["target"],
                   config_path=getattr(args, "config", None))
    send_alert(report, cfg)
    return assessment["exit_code"]


def _establish_asset_baseline(cfg: dict, bm: BaselineManager, snap_dir: Path | None) -> None:
    """Hasht die Dateien des freigegebenen Stands und legt sie als Vergleich an."""
    snap = bm.load_snapshot(snap_dir)
    if not snap:
        return
    asset_urls = _collect_asset_urls(snap)
    if not asset_urls:
        return
    print(f"Richte Datei-Überwachung ein ({len(asset_urls)} Dateien werden geprüft) ...")
    raw_hashes = fetch_asset_hashes(list(asset_urls.keys()), timeout=cfg.get("request_timeout", 15))
    hashes_to_save = {
        url: {**r, "type": asset_urls[url]}
        for url, r in raw_hashes.items()
        if r.get("sha256")
    }
    bm.save_asset_hashes(hashes_to_save)
    print(f"Datei-Überwachung eingerichtet: {len(hashes_to_save)} Dateien.")


def cmd_approve(args: argparse.Namespace, cfg: dict) -> int:
    logger = logging.getLogger("scanner.approve")
    bm = BaselineManager(cfg["data_dir"])
    snap_dir = Path(args.snapshot) if getattr(args, "snapshot", None) else None
    note = getattr(args, "note", "") or ""
    skip_assets = getattr(args, "skip_assets", False)

    if getattr(args, "rebuild", False):
        approved = bm.rebuild_baseline(snap_dir=snap_dir, note=note)
        print(f"Ihre Website wurde als neuer Vergleichsstand gespeichert ({len(approved)} Seiten).")
        if not skip_assets:
            _establish_asset_baseline(cfg, bm, snap_dir)
        _dismiss_ai_after_approve(bm)
        return 0

    if getattr(args, "all", False):
        approved = bm.approve_all(snap_dir=snap_dir, note=note)
        print(f"Ihre Website wurde als Vergleichsstand gespeichert ({len(approved)} Seiten).")
        print("Ab jetzt meldet das Programm jede spätere Veränderung.")
        if not skip_assets:
            _establish_asset_baseline(cfg, bm, snap_dir)
        _dismiss_ai_after_approve(bm)
        return 0

    url = getattr(args, "url", None)
    if url:
        ok = bm.approve_url(url, snap_dir=snap_dir, note=note)
        if ok:
            print(f"Diese Seite wurde als in Ordnung bestätigt: {url}")
            _dismiss_ai_after_approve(bm, url=url)
            return 0
        else:
            print(f"Diese Adresse wurde im letzten Stand nicht gefunden: {url}", file=sys.stderr)
            return 1

    print("Bitte geben Sie an, was freigegeben werden soll: --all, --url <Adresse> oder --rebuild.",
          file=sys.stderr)
    return 1


def _dismiss_ai_after_approve(bm: BaselineManager, url: str | None = None) -> None:
    """Freigegebene Inhalte gelten als geprüft → zugehörige KI-Funde quittieren."""
    if url:
        ledger = bm.load_ai_ledger()
        fps = [fp for fp, e in ledger.get("entries", {}).items() if e.get("url") == url]
        n = bm.dismiss_ai_entries(fps) if fps else 0
    else:
        n = bm.dismiss_ai_entries(None)
    if n:
        print(f"  ({n} offene(r) KI-Fund(e) als geprüft mitquittiert.)")


def cmd_ai_dismiss(args: argparse.Namespace, cfg: dict) -> int:
    """Markiert KI-Funde als geprüft/akzeptiert (Fehlalarm-Quittung)."""
    target_hash = getattr(args, "hash", None)
    if not target_hash and not getattr(args, "all", False):
        print("Bitte angeben, was quittiert werden soll: --hash <Fingerprint> oder --all.",
              file=sys.stderr)
        return 1
    bm = BaselineManager(cfg["data_dir"])
    hashes = [target_hash] if target_hash else None
    n = bm.dismiss_ai_entries(hashes)
    if target_hash and n == 0:
        print(f"Kein offener KI-Fund mit Fingerprint {target_hash} gefunden.")
        return 1
    print(f"{n} KI-Fund(e) als geprüft/akzeptiert markiert.")
    return 0


def _print_diff_only(report: dict) -> None:
    """Print a compact colored diff view of the latest report."""
    a = report.get("assessment", {})
    d = report.get("diff", {})
    level = a.get("level", "green")
    symbol, _ = ampel(level)
    use_color = sys.stdout.isatty()

    if use_color:
        C = {"add": "\033[32m", "del": "\033[31m", "hdr": "\033[36m",
             "url": "\033[1;34m", "rst": "\033[0m"}
    else:
        C = {k: "" for k in ("add", "del", "hdr", "url", "rst")}

    print(f"{symbol}  Level: {level.upper()}   Score: {a.get('score', 0)}")
    print()

    for domain in d.get("new_external_domains", []):
        print(f"{C['del']}+ Neue externe Domain: {domain}{C['rst']}")
    for url in d.get("new_internal_urls", []):
        print(f"{C['add']}+ Neue URL: {url}{C['rst']}")
    for url in d.get("missing_internal_urls", []):
        print(f"{C['del']}- Fehlende URL: {url}{C['rst']}")
    for entry in d.get("new_broken_urls", []):
        print(f"{C['del']}! Neuer kaputt Link [{entry['status']}]: {entry['url']}{C['rst']}")

    has_page_diffs = False
    for pd in d.get("page_diffs", []):
        text_diff = pd.get("text_diff", [])
        link_diff = pd.get("link_diff", {})
        new_hidden = pd.get("new_hidden_content", [])
        new_refresh = pd.get("new_meta_refresh", [])
        new_scripts = pd.get("new_inline_scripts", [])
        new_comments = pd.get("new_comment_links", [])
        if not (text_diff or link_diff or new_hidden or new_refresh
                or new_scripts or new_comments or pd.get("canonical_changed")):
            continue
        has_page_diffs = True
        print(f"\n{C['url']}{'─' * 4} {pd['url']} {'─' * 4}{C['rst']}")
        for line in text_diff:
            if line.startswith("+"):
                print(f"{C['add']}{line}{C['rst']}")
            elif line.startswith("-"):
                print(f"{C['del']}{line}{C['rst']}")
            elif line.startswith("@"):
                print(f"{C['hdr']}{line}{C['rst']}")
            else:
                print(line)
        for ltype, ld in link_diff.items():
            for added in ld.get("added", []):
                print(f"{C['add']}+ [{ltype}] {added}{C['rst']}")
            for removed in ld.get("removed", []):
                print(f"{C['del']}- [{ltype}] {removed}{C['rst']}")
        for h in new_hidden:
            print(f"{C['del']}! Hidden Content ({h.get('type')}): "
                  f"{', '.join(h.get('indicators', []))}{C['rst']}")
        for mr in new_refresh:
            print(f"{C['del']}! Meta-Refresh → {mr}{C['rst']}")
        if pd.get("canonical_changed"):
            print(f"{C['del']}! Canonical: {pd.get('old_canonical')} → {pd.get('new_canonical')}{C['rst']}")
        for s in new_scripts:
            print(f"{C['del']}! Inline-Script: {', '.join(s.get('patterns', []))}{C['rst']}")
        for cl in new_comments:
            print(f"{C['del']}! Kommentar-Link: {cl}{C['rst']}")

    ai = report.get("ai_result") or {}
    ai_findings = ai.get("findings", [])
    for f in ai_findings:
        asset = f.get("asset_url")
        ziel = f"{asset} (auf {f['url']})" if asset else f["url"]
        print(f"{C['del']}! KI [{f['kind']}] {f['category']} "
              f"({f['confidence']:.0%}): {ziel}{C['rst']}")
        if f.get("explanation"):
            print(f"    {f['explanation']}")
    ai_unchecked = ai.get("unchecked", [])
    for u in ai_unchecked:
        ziel = u.get("asset_url") or u.get("url", "?")
        print(f"{C['hdr']}? KI nicht geprüft [{u.get('kind', '?')}]: {ziel}{C['rst']}")

    nothing = (
        not d.get("new_external_domains")
        and not d.get("new_internal_urls")
        and not d.get("missing_internal_urls")
        and not d.get("new_broken_urls")
        and not has_page_diffs
        and not ai_findings
        and not ai_unchecked
    )
    if nothing:
        print("Keine Änderungen.")


def cmd_inventory(args: argparse.Namespace, cfg: dict) -> int:
    """KI-Inhaltsinventar (Objekt → URL, Hash, Inhaltsangabe) anzeigen/suchen/exportieren."""
    bm = BaselineManager(cfg["data_dir"])
    if getattr(args, "csv", None):
        n = bm.export_inventory_csv(args.csv)
        print(f"{n} Objekt(e) nach {args.csv} exportiert.")
        return 0
    rows = bm.query_inventory(search=getattr(args, "search", None), kind=getattr(args, "kind", None))
    if not rows:
        print("Inventar ist leer (noch keine KI-Analyse gelaufen oder kein Treffer).")
        return 0
    from collections import Counter
    kinds = Counter(r["kind"] for r in rows)
    print(f"Inventar: {len(rows)} Objekt(e) — " + ", ".join(f"{k}: {v}" for k, v in sorted(kinds.items())))
    print()
    for r in rows:
        desc = (r.get("description") or "").replace("\n", " ").strip()
        if len(desc) > 90:
            desc = desc[:87] + "…"
        flag = "" if (r.get("category") or "clean") == "clean" else f"  [{r['category']}]"
        print(f"  [{r['kind']:5s}] {(r.get('hash') or '')[:12]}  {r.get('url', '')}{flag}")
        if desc:
            print(f"             {desc}")
    return 0


def cmd_report(args: argparse.Namespace, cfg: dict) -> int:
    rd = Path(cfg["reports_dir"])
    if not rd.exists():
        print("Noch keine Reports vorhanden.")
        return 0

    dirs = sorted(d for d in rd.iterdir() if d.is_dir())
    if not dirs:
        print("Noch keine Reports vorhanden.")
        return 0

    latest = dirs[-1]

    if getattr(args, "diff_only", False):
        p = latest / "report.json"
        if not p.exists():
            print(f"Report-Datei nicht gefunden: {p}", file=sys.stderr)
            return 1
        _print_diff_only(json.loads(p.read_text(encoding="utf-8")))
        return 0

    fmt = getattr(args, "format", "md")
    p = latest / ("report.json" if fmt == "json" else "report.md")
    if not p.exists():
        print(f"Report-Datei nicht gefunden: {p}", file=sys.stderr)
        return 1

    print(p.read_text(encoding="utf-8"))
    return 0


def cmd_status(args: argparse.Namespace, cfg: dict) -> int:
    bm = BaselineManager(cfg["data_dir"])

    # Ampel zuoberst: letzter bekannter Zustand
    rd = Path(cfg["reports_dir"])
    last_report = None
    if rd.exists():
        dirs = sorted(d for d in rd.iterdir() if d.is_dir())
        for d in reversed(dirs):
            rp = d / "report.json"
            if rp.exists():
                last_report = json.loads(rp.read_text(encoding="utf-8"))
                break
    if last_report:
        lvl = last_report.get("assessment", {}).get("level", "green")
        print(ampel_zeile(lvl))
    else:
        print("Noch keine Prüfung durchgeführt.")
    print()

    print(f"Ihre Website:        {cfg['target']}")

    if bm.baseline_exists():
        manifest = json.loads(
            (bm.baseline_dir / "manifest.json").read_text(encoding="utf-8")
        )
        print(f"Vergleichsstand:     vom {manifest.get('approved_at', '?')[:10]} "
              f"({len(manifest.get('urls', []))} Seiten)")
        if manifest.get("note"):
            print(f"  Notiz:             {manifest['note']}")
    else:
        print("Vergleichsstand:     noch keiner — bitte einmal `approve --all` ausführen")

    snap_dir = bm.latest_snapshot_dir()
    if snap_dir:
        snap_manifest = json.loads((snap_dir / "manifest.json").read_text(encoding="utf-8"))
        print(f"Letzte Prüfung:      {snap_manifest.get('timestamp', '?')}")
    else:
        print("Letzte Prüfung:      noch keine")

    # Wann laufen die Wochen-Prüfungen das nächste Mal?
    state = bm.load_state().get("last_runs", {})
    if state:
        labels = {"cloak_check": "Tarnungs-Prüfung", "assets": "Datei-Prüfung",
                  "ext_links": "Externe Links"}
        print("Wöchentliche Zusatzprüfungen — zuletzt gelaufen:")
        for key, label in labels.items():
            ts = state.get(key, "noch nie")
            print(f"  {label}: {ts[:10] if ts != 'noch nie' else ts}")

    # KI-Inhaltsanalyse
    if cfg.get("ai_analysis", {}).get("enabled"):
        ledger = bm.load_ai_ledger().get("entries", {})
        offen = sum(
            1 for e in ledger.values()
            if e.get("category", "clean") != "clean" and not e.get("dismissed")
        )
        print(f"KI-Inhaltsanalyse:   aktiv — {len(ledger)} Inhalt(e) im Cache, {offen} offene(r) Fund(e)")
    else:
        print("KI-Inhaltsanalyse:   deaktiviert")
    return 0


# ---------------------------------------------------------------------------
# Cloaking check
# ---------------------------------------------------------------------------

def cmd_cloak_check(args: argparse.Namespace, cfg: dict) -> int:
    logger = logging.getLogger("scanner.cloak")
    print(f"Tarnungs-Prüfung für {cfg['target']} (zwei Durchläufe) ...")
    cloak_diff, assessment = _run_cloak_core(cfg)
    bm = BaselineManager(cfg["data_dir"])
    bm.mark_check_run("cloak_check")

    report = {
        "generated_at": datetime.now(timezone.utc).isoformat(),
        "target": cfg["target"],
        "type": "cloak-check",
        "assessment": assessment,
        "cloak_diff": cloak_diff,
    }
    json_path, md_path = _write_cloak_report(report, cfg["reports_dir"])
    logger.info("Cloak-Report: %s", md_path)

    _print_cloak_summary(assessment, cloak_diff)
    return assessment["exit_code"]


# ---------------------------------------------------------------------------
# External link check
# ---------------------------------------------------------------------------

def cmd_check_ext_links(args: argparse.Namespace, cfg: dict) -> int:
    logger = logging.getLogger("scanner.ext-links")
    bm = BaselineManager(cfg["data_dir"])

    snap = bm.load_snapshot()
    if not snap:
        print("Es gibt noch keinen aktuellen Stand. Bitte zuerst:  python -m scanner scan")
        return 1

    print("Prüfe die externen Links Ihrer Website ...")
    result = _run_ext_links_core(cfg, snap)
    bm.mark_check_run("ext_links")

    if result["total"] == 0:
        print("Ihre Website verweist nicht auf andere Websites.")
        return 0

    report = {
        "generated_at": datetime.now(timezone.utc).isoformat(),
        "target": cfg["target"],
        "type": "ext-links",
        **result,
    }
    _write_ext_links_report(report, cfg["reports_dir"])
    _print_ext_links_summary(report)
    return 0 if not result["broken"] and not result["errors"] else 1


# ---------------------------------------------------------------------------
# Asset check
# ---------------------------------------------------------------------------

def cmd_check_assets(args: argparse.Namespace, cfg: dict) -> int:
    logger = logging.getLogger("scanner.assets")
    bm = BaselineManager(cfg["data_dir"])

    snap = bm.load_snapshot()
    if not snap:
        print("Es gibt noch keinen aktuellen Stand. Bitte zuerst:  python -m scanner scan")
        return 1

    if not _collect_asset_urls(snap):
        print("Ihre Website enthält keine prüfbaren Dateien (Bilder/Skripte/Stylesheets).")
        return 0

    print("Prüfe die Dateien Ihrer Website (Bilder/Skripte/Stylesheets) ...")
    asset_diff, assessment, has_baseline = _run_assets_core(cfg, bm, snap)
    bm.mark_check_run("assets")
    _invalidate_ai_for_changed_assets(bm, asset_diff)

    report = {
        "generated_at": datetime.now(timezone.utc).isoformat(),
        "target": cfg["target"],
        "type": "asset-check",
        "assessment": assessment,
        "asset_diff": asset_diff,
    }
    _write_asset_report(report, cfg["reports_dir"])
    _print_asset_summary(assessment, asset_diff)

    if not has_baseline:
        print()
        print("Hinweis: Es war noch keine Datei-Vergleichsgrundlage vorhanden.")
        print("Diese wird automatisch angelegt, sobald Sie mit `approve --all` freigeben.")

    return assessment["exit_code"]


def cmd_approve_assets(args: argparse.Namespace, cfg: dict) -> int:
    logger = logging.getLogger("scanner.approve-assets")
    bm = BaselineManager(cfg["data_dir"])

    snap = bm.load_snapshot()
    if not snap:
        print(
            "FEHLER: Kein Snapshot vorhanden. Zuerst `python -m scanner crawl` ausführen.",
            file=sys.stderr,
        )
        return 1

    asset_urls = _collect_asset_urls(snap)
    if not asset_urls:
        print("Keine Assets gefunden.")
        return 0

    print(f"Hashe {len(asset_urls)} Assets für neue Baseline ...")
    raw_hashes = fetch_asset_hashes(
        list(asset_urls.keys()),
        timeout=cfg.get("request_timeout", 15),
    )
    hashes_to_save = {
        url: {**r, "type": asset_urls[url]}
        for url, r in raw_hashes.items()
        if r.get("sha256")  # only store successfully fetched assets
    }
    bm.save_asset_hashes(hashes_to_save)
    note = getattr(args, "note", "") or ""
    print(f"Asset-Baseline gesetzt: {len(hashes_to_save)} Dateien.")
    if note:
        print(f"Notiz: {note}")
    return 0


# ---------------------------------------------------------------------------
# Output helper
# ---------------------------------------------------------------------------

def _print_kein_baseline_hinweis() -> None:
    """Klartext-Anleitung, wenn noch kein Vergleichsstand (Baseline) existiert."""
    print("Es gibt noch keinen gespeicherten Vergleichsstand Ihrer Website.")
    print("Wenn Ihre Website gerade in Ordnung ist, speichern Sie diesen Zustand einmalig als")
    print("Vergleichsgrundlage. Ab dann meldet das Programm jede spätere Veränderung.")
    print()
    print("So geht's:   python -m scanner approve --all")


def _print_summary(assessment: dict, diff: dict,
                   cloak_diff: dict | None = None,
                   asset_diff: dict | None = None,
                   ext_links: dict | None = None,
                   ai_result: dict | None = None,
                   target: str = "",
                   config_path: str | None = None) -> None:
    level = assessment.get("level", "green")
    if config_path:
        scan_cmd  = f"python -m scanner --config {config_path} scan"
        report_cmd = f"python -m scanner --config {config_path} report"
    else:
        scan_cmd  = scanner_cmd(target, "scan")
        report_cmd = scanner_cmd(target, "report")
    print()
    print(ampel_zeile(level))
    print()
    for satz in klartext_befunde(diff, cloak_diff, asset_diff, ext_links, ai_result):
        print(f"  • {satz}")
    print()
    for schritt in was_tun(level, target):
        print(f"  → {schritt}")
    # Knappe technische Kennzahlen darunter (für den Dienstleister)
    print()
    print(f"  — Ausgeführt mit:  {scan_cmd}")
    print(f"  — Technische Details: Score {assessment.get('score', 0)}, "
          f"{diff.get('changed_pages', 0)} geänderte Seite(n), "
          f"{len(diff.get('new_internal_urls', []))} neue / "
          f"{len(diff.get('missing_internal_urls', []))} fehlende Seite(n). "
          f"Vollständiger Bericht:  {report_cmd}")


def cmd_test_alert(args: argparse.Namespace, cfg: dict) -> int:
    """Send a synthetic test alert to verify SMTP configuration."""
    import os
    level = args.level
    target = cfg.get("target", "?")
    email_cfg = cfg.get("alerting", {}).get("email", {})

    if not email_cfg.get("enabled"):
        print("E-Mail-Alarm ist in der config.yaml nicht aktiviert (alerting.email.enabled: false).")
        return 1

    pw_env = email_cfg.get("smtp_password_env", "SCANNER_SMTP_PASSWORD")
    if not os.environ.get(pw_env):
        print(f"Umgebungsvariable {pw_env} ist nicht gesetzt.")
        print(f"Bitte in ~/.bashrc eintragen: export {pw_env}=\"Passwort\"")
        return 1

    report = {
        "target": target,
        "assessment": {
            "level": level,
            "score": 42 if level == "yellow" else 120,
            "reasons": ["[TEST] Dies ist eine Test-Benachrichtigung des integrity-scanner."],
            "exit_code": 1 if level == "yellow" else 2,
        },
        "diff": {
            "new_external_domains": [],
            "new_internal_urls": [],
            "missing_internal_urls": [],
            "page_diffs": [],
            "new_broken_urls": [],
            "known_broken_urls": [],
        },
    }

    print(f"Sende Test-Alarm ({level}) an {email_cfg.get('to', [])} ...")
    send_alert(report, cfg)
    print("Fertig. Bitte E-Mail-Postfach prüfen.")
    return 0


# ---------------------------------------------------------------------------
# Argument parser
# ---------------------------------------------------------------------------

def _build_parser() -> argparse.ArgumentParser:
    p = argparse.ArgumentParser(
        prog="scanner",
        description="Website-Integrity-Scanner für statische Webseiten",
    )
    p.add_argument("--config", default="config.yaml", help="Pfad zur config.yaml")
    p.add_argument("--verbose", "-v", action="store_true", help="Debug-Ausgabe")

    sub = p.add_subparsers(dest="command", required=True)

    sub.add_parser("init", help="Erstcrawl + Baseline-Workflow starten")

    sub.add_parser("crawl", help="Nur crawlen, Snapshot speichern")

    sub.add_parser("check", help="Snapshot vs. Baseline vergleichen")

    sub.add_parser("scan", help="crawl + check (für Cron)")

    ap = sub.add_parser("approve", help="Snapshot als neue Baseline freigeben")
    ap.add_argument("--all", action="store_true", help="Alle URLs freigeben")
    ap.add_argument("--url", help="Einzelne URL freigeben")
    ap.add_argument("--rebuild", action="store_true", help="Baseline komplett ersetzen")
    ap.add_argument("--snapshot", help="Pfad zu einem bestimmten Snapshot-Verzeichnis")
    ap.add_argument("--note", default="", help="Freitext-Notiz für die Baseline-Versionierung")
    ap.add_argument("--skip-assets", action="store_true",
                    help="Datei-Überwachung NICHT automatisch mit anlegen")

    rp = sub.add_parser("report", help="Letzten Report anzeigen")
    rp.add_argument("--format", choices=["md", "json"], default="md")
    rp.add_argument("--diff-only", action="store_true",
                    help="Nur Textdiffs geänderter Seiten anzeigen (farbig)")

    sub.add_parser("status", help="Übersichtsstatus anzeigen")

    sub.add_parser("cloak-check", help="Doppel-Crawl: Browser-UA vs. Googlebot-UA (Cloaking-Erkennung)")

    sub.add_parser("check-ext-links", help="Externe Links auf Erreichbarkeit prüfen (4xx/5xx)")

    sub.add_parser("check-assets", help="Binärdateien (JS/CSS/Bilder) auf Änderungen prüfen")

    aa = sub.add_parser("approve-assets", help="Aktuelle Asset-Hashes als neue Baseline setzen")
    aa.add_argument("--note", default="", help="Freitext-Notiz")

    ta = sub.add_parser("test-alert", help="Test-E-Mail senden (prüft SMTP-Verbindung)")
    ta.add_argument(
        "--level", choices=["yellow", "red"], default="yellow",
        help="Simulierter Alarm-Level (default: yellow)",
    )

    ad = sub.add_parser("ai-dismiss", help="KI-Funde als geprüft/akzeptiert markieren (Fehlalarm-Quittung)")
    ad.add_argument("--hash", help="Nur diesen Fingerprint quittieren")
    ad.add_argument("--all", action="store_true", help="Alle offenen KI-Funde quittieren")

    iv = sub.add_parser("inventory", help="KI-Inhaltsinventar anzeigen/durchsuchen/exportieren")
    iv.add_argument("--search", help="Volltextsuche über Inhaltsangabe/URL/Kategorie")
    iv.add_argument("--kind", choices=["text", "image", "audio", "video"],
                    help="Nur diesen Objekttyp anzeigen")
    iv.add_argument("--csv", help="Inventar als CSV-Datei exportieren (Pfad)")

    return p


# ---------------------------------------------------------------------------
# Main
# ---------------------------------------------------------------------------

def _auto_setup(url: str, domain: str) -> None:
    """Create a new target directory from the meine-seite.de template."""
    project_root = Path(__file__).parent.parent
    template_dir = project_root / "meine-seite.de"
    target_dir   = project_root / domain

    if not template_dir.exists():
        print(
            f"[scanner] Fehler: Template-Verzeichnis '{template_dir}' nicht gefunden.\n"
            f"[scanner] Bitte sicherstellen, dass 'meine-seite.de/' im Projektverzeichnis existiert.",
            file=sys.stderr,
        )
        sys.exit(1)

    target_dir.mkdir(parents=True, exist_ok=True)

    config_dst = target_dir / "config.yaml"
    if not config_dst.exists():
        content = (template_dir / "config.yaml").read_text(encoding="utf-8")
        # Absolute Pfade anpassen
        content = content.replace(
            str(project_root / "meine-seite.de"),
            str(project_root / domain),
        )
        # URL und Domain ersetzen
        content = content.replace("https://meine-seite.de", url.rstrip("/"))
        content = content.replace("meine-seite.de", domain)
        config_dst.write_text(content, encoding="utf-8")

    config_src_dir = template_dir / "config"
    config_dst_dir = target_dir / "config"
    if config_src_dir.exists() and not config_dst_dir.exists():
        shutil.copytree(config_src_dir, config_dst_dir)

    print(f"[scanner] Neues Target angelegt: {target_dir}")
    print(f"[scanner] Bitte .gitignore ergänzen:")
    print(f"[scanner]   {domain}/data/")
    print(f"[scanner]   {domain}/reports/")
    print(f"[scanner]   {domain}/logs/")
    print()


def main() -> None:
    argv = list(sys.argv[1:])

    # URL-first syntax: scanner https://domain.de scan [options]
    if argv and re.match(r"https?://", argv[0]):
        url = argv.pop(0)
        domain = urlparse(url).netloc
        if not domain:
            print(f"[scanner] Fehler: Ungültige URL: {url}", file=sys.stderr)
            sys.exit(1)

        if not Path(domain).exists():
            _auto_setup(url, domain)

        argv = ["--config", str(Path(domain) / "config.yaml")] + argv

    parser = _build_parser()
    args = parser.parse_args(argv)

    cfg = load_config(args.config)
    base_dir = Path(args.config).parent.resolve()
    resolve_paths(cfg, base_dir)

    _setup_logging(cfg["logs_dir"], args.verbose)

    commands = {
        "init": cmd_init,
        "crawl": cmd_crawl,
        "check": cmd_check,
        "scan": cmd_scan,
        "approve": cmd_approve,
        "report": cmd_report,
        "inventory": cmd_inventory,
        "status": cmd_status,
        "cloak-check": cmd_cloak_check,
        "check-ext-links": cmd_check_ext_links,
        "check-assets": cmd_check_assets,
        "approve-assets": cmd_approve_assets,
        "test-alert": cmd_test_alert,
        "ai-dismiss": cmd_ai_dismiss,
    }
    handler = commands.get(args.command)
    if not handler:
        parser.print_help()
        sys.exit(1)

    sys.exit(handler(args, cfg))


if __name__ == "__main__":
    main()
