"""Erweiterungen als Pakete - ein Ordner je Erweiterung, wie in VS Code.

    manifest.json   Name, Titel, Version, Schalter (standard, extern, bereit, fest)
                    und die Beiträge zur Oberfläche: Kategorien (aktivitaeten),
                    Abschnitte, Ansichten, Befehle, Reiter der unteren Leiste,
                    Einstellungen; dazu skripte/stile unter static/.
    __init__.py     optional: router (APIRouter), AUSFUEHRER (Auftragsarten),
                    EREIGNISSE (Hooks wie projekt_geloescht)
    static/         wird unter /erweiterungen/<name>/static ausgeliefert

Eingebaute Pakete liegen hier („offiziell“), fremde in DATEN/erweiterungen
(ERWEITERUNGEN_DIR, „lokal“). Fremde laufen mit vollem Vertrauen im selben
Prozess - wie der Extension-Host von VS Code; die Oberfläche sagt das dazu.
Ein kaputtes Manifest oder ein Paket, dessen Code nicht lädt, wird übersprungen
und protokolliert: ein fremdes Paket darf den Dienst nicht zu Fall bringen.
Das Manifest ist die Wahrheit für Menü, Kategorien und Schalter; web/module.py
leitet daraus ab, was für eine Person wirksam ist.
"""
from __future__ import annotations

import importlib.util
import json
import logging
import os
import re
import sys
from pathlib import Path

from .. import DATEN

HIER = Path(__file__).resolve().parent
FREMD = Path(os.environ.get("ERWEITERUNGEN_DIR", DATEN / "erweiterungen")).resolve()
NAME = re.compile(r"^[a-z][a-z0-9_]{1,30}$")
LISTEN = ("aktivitaeten", "abschnitte", "ansichten", "befehle", "unten", "einstellungen")
EREIGNISSE: dict[str, list] = {}                # ereignis -> [handler]


def _lesen(ordner: Path, quelle: str) -> dict | None:
    try:
        m = json.loads((ordner / "manifest.json").read_text(encoding="utf-8"))
        for feld in ("name", "titel", "version"):
            assert isinstance(m.get(feld), str) and m[feld], f"{feld} fehlt"
        assert m["name"] == ordner.name and NAME.match(m["name"]), "name passt nicht zum Ordner"
        for feld, wert in (("beschreibung", ""), ("standard", False), ("extern", False),
                           ("bereit", True), ("fest", False), ("reihenfolge", 50),
                           ("beitraege", {}), ("skripte", []), ("stile", [])):
            m.setdefault(feld, wert)
        assert all(isinstance(x, str) and x.startswith("static/") and ".." not in x
                   for x in m["skripte"] + m["stile"]), "skripte und stile liegen unter static/"
        # Die Richtlinie als Manifestprüfung: nichts, was nach außen sendet, ist Vorgabe.
        assert not (m["extern"] and m["standard"]), "extern darf nicht Vorgabe sein"
        assert isinstance(m["beitraege"], dict), "beitraege ist ein Objekt"
        for liste in LISTEN:
            m["beitraege"].setdefault(liste, [])
            assert isinstance(m["beitraege"][liste], list), f"beitraege.{liste} ist eine Liste"
        m["quelle"], m["_ordner"] = quelle, ordner
        return m
    except (OSError, ValueError, AssertionError, AttributeError) as e:
        logging.warning("Erweiterung %s übersprungen: %s", ordner.name, e)
        return None


def katalog() -> dict[str, dict]:
    """Alle Pakete, eingebaute zuerst; bei gleichem Namen gilt das erste."""
    k: dict[str, dict] = {}
    for basis, quelle in ((HIER, "offiziell"), (FREMD, "lokal")):
        if not basis.is_dir():
            continue
        for ordner in sorted(p for p in basis.iterdir() if (p / "manifest.json").is_file()):
            m = _lesen(ordner, quelle)
            if m and m["name"] in k:
                logging.warning("Erweiterung %s doppelt (%s) - die eingebaute gilt", m["name"], quelle)
            elif m:
                k[m["name"]] = m
    return dict(sorted(k.items(), key=lambda x: (x[1]["reihenfolge"], x[0])))


KATALOG = katalog()


def beitraege(wirksam: dict[str, bool]) -> dict:
    """Die Oberfläche der wirksamen Pakete, zusammengeführt: Kategorien mit
    Abschnitten und deren Ansichten; leere Abschnitte und Kategorien fallen weg.
    Der Kern („fest“) ist immer dabei."""
    aktiv = [m for m in KATALOG.values() if m["fest"] or wirksam.get(m["name"])]
    kategorien: dict[str, dict] = {}
    for m in aktiv:
        for a in m["beitraege"]["aktivitaeten"]:
            kategorien[a["id"]] = {**a, "erweiterung": m["name"], "abschnitte": {}}
    for m in aktiv:
        for ab in m["beitraege"]["abschnitte"]:
            k = kategorien.get(ab.get("aktivitaet"))
            if k is None:
                logging.warning("%s: Abschnitt %s ohne Kategorie", m["name"], ab.get("id"))
                continue
            k["abschnitte"][ab["id"]] = {**ab, "erweiterung": m["name"], "ansichten": []}
    abschnitte = {ab_id: ab for k in kategorien.values() for ab_id, ab in k["abschnitte"].items()}
    ansichten = []
    for m in aktiv:
        for an in m["beitraege"]["ansichten"]:
            ab = abschnitte.get(an.get("abschnitt"))
            if ab is None:
                logging.warning("%s: Ansicht %s ohne Abschnitt", m["name"], an.get("id"))
                continue
            eintrag = {**an, "erweiterung": m["name"], "ohne_projekt": bool(an.get("ohne_projekt"))}
            ab["ansichten"].append(eintrag)
            ansichten.append(eintrag)
    fertig = []
    for k in sorted(kategorien.values(), key=lambda a: (a.get("reihenfolge", 50), a["id"])):
        k["abschnitte"] = [ab for ab in k["abschnitte"].values()
                           if ab.get("art", "liste") != "liste" or ab["ansichten"]]
        if k["abschnitte"]:
            fertig.append(k)

    def gesammelt(liste):
        return [{**x, "erweiterung": m["name"]} for m in aktiv for x in m["beitraege"][liste]]

    def urls(liste):
        return [f"/erweiterungen/{m['name']}/static/{x[len('static/'):]}"
                for m in aktiv for x in m[liste]]
    return {"aktivitaeten": fertig, "ansichten": ansichten, "befehle": gesammelt("befehle"),
            "unten": gesammelt("unten"), "einstellungen": gesammelt("einstellungen"),
            "skripte": urls("skripte"), "stile": urls("stile")}


def einbinden(app) -> None:
    """Statik einhängen und den Code der Pakete laden - eingebaute wie fremde
    über den Dateipfad, damit das gefrorene Programm (PyInstaller) nichts
    Besonderes braucht. Gerufen am Ende von web/app.py: was ein Paket aus
    web.app importiert, ist dann schon da."""
    from fastapi.staticfiles import StaticFiles

    from .. import agenten
    for m in KATALOG.values():
        statik = m["_ordner"] / "static"
        if statik.is_dir():
            app.mount(f"/erweiterungen/{m['name']}/static", StaticFiles(directory=statik),
                      name=f"erweiterung-{m['name']}")
        init = m["_ordner"] / "__init__.py"
        if not init.is_file():
            continue
        try:
            spec = importlib.util.spec_from_file_location(
                f"web.erweiterungen.{m['name']}", init,
                submodule_search_locations=[str(m["_ordner"])])
            modul = importlib.util.module_from_spec(spec)
            sys.modules[spec.name] = modul
            spec.loader.exec_module(modul)
            if getattr(modul, "router", None) is not None:
                app.include_router(modul.router)
            agenten.AUSFUEHRER.update(getattr(modul, "AUSFUEHRER", {}))
            for name, fn in getattr(modul, "EREIGNISSE", {}).items():
                EREIGNISSE.setdefault(name, []).append(fn)
        except Exception:                          # noqa: BLE001 - fremder Code
            logging.exception("Erweiterung %s lädt nicht und bleibt außen vor", m["name"])
            sys.modules.pop(f"web.erweiterungen.{m['name']}", None)


def ereignis(name: str, *args) -> None:
    """Hooks der Pakete rufen; ein Fehler dort bleibt dort."""
    for fn in EREIGNISSE.get(name, []):
        try:
            fn(*args)
        except Exception:                          # noqa: BLE001
            logging.exception("Erweiterung bei %s", name)
