"""Brython entry for desktop app (``/``). Phase 7: DocX export + gallery 4:3 crop."""
from browser import document, window, timer, ajax, html
import json
import time

storage = window.localStorage

_VERSION_EL = document["deploy-version-data"]
try:
    DEPLOY_VERSION = json.loads(_VERSION_EL.text or '""')
except Exception:
    DEPLOY_VERSION = "UNKNOWN"

ACTIVE_TAB = "maengel"
EXPERT_MODE = False
TAB_IDS = ("maengel", "recherche", "manager", "news")

NAV_MODE = "customers"  # customers | maengel
ACTIVE_CUSTOMER_ID = None
ACTIVE_PROJECT_ID = None
ACTIVE_MANGEL_ID = None
LOADED_MANGEL = None
FIELD_META = None
FORM_BUILT = False
CONTEXT_SEQ = 0

# Research session state (Phase 5)
SEARCH_MODE = "semantic"  # semantic | keyword
SEARCH_QUERY = ""
SEARCH_LIMIT = 50
SEARCH_PAGE_SIZE = 50
SEARCH_RESULTS = []  # frozen+appended list of result dicts
SEARCH_EXHAUSTED = False
SEARCH_BUSY = False
SELECTION = {}  # uid -> {selected, citation_mode, manual_text, result}
HIDE_SHORT = True
STALE_LOADED = False
# LEGACY: PNG image preview beside PDF.js. Keep False — code retained for rollback.
USE_LEGACY_PDF_IMAGE_PREVIEW = False
RESEARCH_PDF_EXPAND_BOUND = False
ACTIVE_PDF_UID = None  # result uid currently shown in the PDF viewer
CLIENT_ERRORS = []  # ring buffer max 10 {t, msg}
FEEDBACK_BUSY = False


def _push_client_error(msg):
    global CLIENT_ERRORS
    text = str(msg or "")[:400]
    if not text:
        return
    try:
        t = window.Date.new().toISOString()
    except Exception:
        t = ""
    CLIENT_ERRORS = (CLIENT_ERRORS + [{"t": t, "msg": text}])[-10:]


def _install_client_error_hooks():
    def on_error(message, source, lineno, colno, error):
        parts = [str(message or "error")]
        if source:
            parts.append(f"@{source}:{lineno or '?'}")
        _push_client_error(" ".join(parts))
        return False

    def on_rejection(ev):
        try:
            reason = getattr(ev, "reason", None)
            _push_client_error(f"unhandledrejection: {reason}")
        except Exception:
            _push_client_error("unhandledrejection")

    window.onerror = on_error
    try:
        window.addEventListener("unhandledrejection", on_rejection)
    except Exception:
        pass


def track(action, label=None, target_table="System", target_id=0, input=None, output=None):
    """Best-effort UI activity with standard {label, input, output} delta."""
    delta = {"source": "desktop_brython"}
    if label:
        delta["label"] = label
    if isinstance(input, dict):
        inp = dict(input)
    elif input is None:
        inp = {}
    else:
        inp = {"value": input}
    # Always include current context for reconstruction
    ctx = {
        "active_tab": ACTIVE_TAB,
        "customer_id": ACTIVE_CUSTOMER_ID,
        "project_id": ACTIVE_PROJECT_ID,
        "mangel_id": ACTIVE_MANGEL_ID,
    }
    for k, v in ctx.items():
        if k not in inp:
            inp[k] = v
    delta["input"] = inp
    if output is not None:
        delta["output"] = output
    try:
        api_json(
            "POST",
            "/api/desktop/activity",
            {
                "action": action,
                "target_table": target_table,
                "target_id": int(target_id or 0),
                "delta": delta,
            },
            oncomplete=lambda req: None,
        )
    except Exception:
        pass


def log_client_activity(action, target_table="System", target_id=0, delta=None):
    """Legacy wrapper — prefer track()."""
    d = dict(delta or {})
    track(
        action,
        label=d.pop("label", None),
        target_table=target_table,
        target_id=target_id,
        input=d.pop("input", d if d else None),
        output=d.pop("output", None),
    )

MGR_PDFS = []
MGR_BUSY = False
MGR_ALIAS_DRAFT = {}
MGR_PROGRESS_TIMER = None
MGR_LOCAL_TICK_TIMER = None
MGR_JOB_LABEL = ""
MGR_POLL_SEQ = 0
MGR_LAST_PROGRESS_KEY = ""
MGR_LAST_PROGRESS_CHANGE_AT = 0
MGR_LAST_POLL_OK_AT = 0
MGR_INDEX_STATUS_PATH = "/api/desktop/index/status"
AUDIO_SEEK_DRAGGING = False

# Editor field keys (aligned with NiceGUI mangel editor)
TOP_FIELDS = (
    "mangel_number",
    "room_name",
    "floor",
    "system_part_name",
    "error_source_description_note",
)
BOTTOM_FIELDS = (
    "error_source_description_full",
    "risk_description_full",
    "actions_full",
)
TEXT_FIELDS = TOP_FIELDS + BOTTOM_FIELDS
PRUEF_KEYS_FALLBACK = [
    "mangel_number",
    "room_name",
    "floor",
    "system_part_name",
    "error_source_description_note",
    "extracted_norms",
    "error_source_description_full",
    "risk",
    "priority",
    "risk_description_full",
    "actions_full",
]


def show_toast(msg, ms=2500):
    sb = document["snackbar"]
    sb.text = str(msg)
    sb.classList.add("show")

    def hide():
        sb.classList.remove("show")

    timer.set_timeout(hide, ms)


def get_auth_header():
    token = storage.getItem("access_token")
    return {"Authorization": f"Bearer {token}"} if token else {}


def _set_visible(el_id, visible):
    el = document[el_id]
    if visible:
        el.classList.remove("hidden")
    else:
        el.classList.add("hidden")


def _has_el(el_id):
    try:
        document[el_id]
        return True
    except Exception:
        return False


def _dash(value):
    if value is None or value == "":
        return "—"
    return str(value)


def _as_int(value, default=None):
    """Brython JSON numbers are floats — coerce for FastAPI int query params."""
    if value is None or value == "":
        return default
    try:
        return int(float(value))
    except Exception:
        return default


def _parse_json(req):
    try:
        text = getattr(req, "text", None)
        if text is None:
            text = getattr(req, "responseText", None) or "{}"
        return json.loads(text or "{}")
    except Exception:
        return {}


class _XhrShim:
    """Normalize browser XHR to the shape Brython ajax callbacks expect."""

    def __init__(self, xhr):
        self.status = int(xhr.status or 0)
        self.text = xhr.responseText or ""


def api_json(method, path, payload=None, oncomplete=None):
    headers = dict(get_auth_header())
    method = (method or "GET").upper()

    def handler(req):
        if req.status == 401:
            storage.removeItem("access_token")
            show_login()
            show_toast("Sitzung abgelaufen")
            return
        if oncomplete:
            oncomplete(req)

    # Native XHR for all methods — Brython ajax.get can mangle "?query" URLs.
    body = None
    if payload is not None and method in ("POST", "PUT", "PATCH"):
        body = json.dumps(payload)
        headers["Content-Type"] = "application/json"

    xhr = window.XMLHttpRequest.new()
    xhr.open(method, path, True)
    for k, v in headers.items():
        xhr.setRequestHeader(k, v)

    def _on_ready(ev=None):
        if xhr.readyState != 4:
            return
        handler(_XhrShim(xhr))

    xhr.onreadystatechange = _on_ready
    xhr.send(body)


def render_context(data):
    global ACTIVE_PROJECT_ID, ACTIVE_CUSTOMER_ID, ACTIVE_MANGEL_ID
    data = data or {}
    customer = data.get("customer") or {}
    project = data.get("project") or {}
    mangel = data.get("mangel") or {}
    progress = data.get("progress") or {}

    if project and project.get("id") is not None:
        ACTIVE_PROJECT_ID = _as_int(project.get("id"))
    if customer and customer.get("id") is not None:
        ACTIVE_CUSTOMER_ID = _as_int(customer.get("id"))
    if mangel and mangel.get("id") is not None:
        ACTIVE_MANGEL_ID = _as_int(mangel.get("id"))

    document["ctx_customer"].text = _dash(customer.get("name") if customer else None)

    if project:
        pname = project.get("name") or ""
        pnum = project.get("number")
        document["ctx_project"].text = (
            f"{pname} ({pnum})" if pnum not in (None, "") else _dash(pname)
        )
    else:
        document["ctx_project"].text = "—"

    done = progress.get("done", 0)
    total = progress.get("total", 0)
    bar = document["ctx_progress_bar"]
    if total:
        pct = int(round(100 * done / total))
        document["ctx_progress"].text = f"{done} / {total} ({pct}%)"
        bar.style.width = f"{pct}%"
        if pct >= 100:
            bar.classList.add("is-complete")
        else:
            bar.classList.remove("is-complete")
    else:
        document["ctx_progress"].text = "0 / 0"
        bar.style.width = "0%"
        bar.classList.remove("is-complete")


def clear_context():
    render_context({})


def load_context(mangel_id=None, project_id=None):
    global CONTEXT_SEQ
    CONTEXT_SEQ += 1
    seq = CONTEXT_SEQ
    mangel_id = _as_int(mangel_id)
    project_id = _as_int(project_id)
    path = "/api/desktop/context"
    if mangel_id is not None:
        path = f"/api/desktop/context?mangel_id={mangel_id}"
    elif project_id is not None:
        path = f"/api/desktop/context?project_id={project_id}"

    def on_complete(req):
        if seq != CONTEXT_SEQ:
            return
        if req.status == 200:
            render_context(_parse_json(req))
            return
        if req.status not in (0, 401):
            show_toast(f"Kontext laden fehlgeschlagen ({req.status})")

    api_json("GET", path, oncomplete=on_complete)


def select_project(project, customer):
    global ACTIVE_PROJECT_ID, ACTIVE_CUSTOMER_ID, ACTIVE_MANGEL_ID, LOADED_MANGEL
    pid = _as_int((project or {}).get("id"))
    cid = _as_int((customer or {}).get("id"))
    if pid is None:
        show_toast("Ungültige Projekt-ID")
        return
    ACTIVE_PROJECT_ID = pid
    ACTIVE_CUSTOMER_ID = cid
    ACTIVE_MANGEL_ID = None
    LOADED_MANGEL = None
    show_editor(False)
    # Header names immediately (don't wait for context)
    document["ctx_customer"].text = _dash((customer or {}).get("name"))
    document["ctx_project"].text = _dash(
        (project or {}).get("label")
        or (project or {}).get("name")
    )
    load_context(project_id=pid)
    set_nav_mode("maengel")
    set_tab("maengel")
    track(
        "CONTEXT_SELECT",
        label="Projekt gewählt",
        target_table="Projekt",
        target_id=pid,
        input={
            "kind": "project",
            "customer_id": cid,
            "project_id": pid,
            "project_label": (project or {}).get("label")
            or (project or {}).get("name"),
            "customer_name": (customer or {}).get("name"),
        },
    )


def set_tab(tab_id):
    global ACTIVE_TAB
    prev = ACTIVE_TAB
    ACTIVE_TAB = tab_id if tab_id in TAB_IDS else "maengel"
    for tid in TAB_IDS:
        panel = document[f"tab_{tid}"]
        if tid == ACTIVE_TAB:
            panel.classList.remove("hidden")
        else:
            panel.classList.add("hidden")
    if ACTIVE_TAB == "recherche":
        ensure_research_ready()
        # Tab was display:none — refit PDF after layout, not while hidden (~7% zoom).
        timer.set_timeout(pdf_js_fit_width, 50)
        timer.set_timeout(pdf_js_fit_width, 250)
    elif ACTIVE_TAB == "manager":
        load_manager()
    elif ACTIVE_TAB == "news":
        load_news_page()
    if prev != ACTIVE_TAB:
        track(
            "TAB_CHANGE",
            label=f"Tab: {ACTIVE_TAB}",
            input={"from": prev, "to": ACTIVE_TAB},
        )

def goto_recherche(ev=None):
    track(
        "UI_CLICK",
        label="Button: KI-Recherche",
        target_table="Mangel",
        target_id=ACTIVE_MANGEL_ID or 0,
        input={"button": "btn_goto_recherche"},
    )
    set_tab("recherche")


def goto_maengel(ev=None):
    set_research_pdf_expanded(False)
    set_tab("maengel")


def goto_manager(ev=None):
    set_research_pdf_expanded(False)
    set_tab("manager")


def goto_news(ev=None):
    if UPDATE_AVAILABLE:
        reload_for_new_version()
        return
    track(
        "UI_CLICK",
        label="Button: News",
        input={"button": "btn_news"},
    )
    set_research_pdf_expanded(False)
    set_tab("news")


NEWS_ITEMS = []
NEWS_HIGHLIGHT_TIMER = None
NEWS_SPOT_ID = None
UPDATE_AVAILABLE = False
VERSION_POLL_TIMER = None
VERSION_POLL_MS = 35000


def _fmt_news_date(iso_s):
    if not iso_s:
        return "—"
    try:
        s = str(iso_s)
        if "T" in s:
            return s.replace("T", " ")[:16]
        return s[:16]
    except Exception:
        return str(iso_s)


def _set_news_unread_badge(count):
    if not _has_el("news_unread_badge"):
        return
    badge = document["news_unread_badge"]
    n = int(count or 0)
    if n > 0:
        badge.text = str(n if n < 100 else "99+")
        badge.classList.remove("hidden")
    else:
        badge.text = "0"
        badge.classList.add("hidden")


def _clear_news_highlight():
    global NEWS_HIGHLIGHT_TIMER
    NEWS_HIGHLIGHT_TIMER = None
    if _has_el("news_header"):
        document["news_header"].classList.remove("is-highlight")


def _start_news_highlight():
    global NEWS_HIGHLIGHT_TIMER
    if not _has_el("news_header"):
        return
    document["news_header"].classList.add("is-highlight")
    try:
        if NEWS_HIGHLIGHT_TIMER is not None:
            timer.clear_timeout(NEWS_HIGHLIGHT_TIMER)
    except Exception:
        pass
    NEWS_HIGHLIGHT_TIMER = timer.set_timeout(_clear_news_highlight, 45000)


def refresh_news_header():
    global NEWS_SPOT_ID
    if UPDATE_AVAILABLE:
        return

    def on_complete(req):
        global NEWS_SPOT_ID
        if UPDATE_AVAILABLE:
            return
        if not _has_el("news_header"):
            return
        if req.status != 200:
            return
        data = _parse_json(req) or {}
        latest = data.get("latest")
        unread = int(data.get("unread_count") or 0)
        _set_news_unread_badge(unread)
        title_el = document["news_spot_title"]
        if latest and latest.get("title"):
            title_el.text = str(latest.get("title"))
            NEWS_SPOT_ID = latest.get("id")
        else:
            title_el.text = "Keine News"
            NEWS_SPOT_ID = None
        if data.get("highlight") and latest and latest.get("is_unread"):
            _start_news_highlight()
        else:
            _clear_news_highlight()

    api_json("GET", "/api/desktop/news/latest", oncomplete=on_complete)


def reload_for_new_version(ev=None):
    try:
        window.location.reload()
    except Exception:
        window.location.href = "/"


def _set_update_banner(active):
    global UPDATE_AVAILABLE
    UPDATE_AVAILABLE = bool(active)
    if not _has_el("news_header"):
        return
    header = document["news_header"]
    if UPDATE_AVAILABLE:
        _clear_news_highlight()
        header.classList.add("is-update")
        if _has_el("news_spot_kicker"):
            document["news_spot_kicker"].text = "Update"
        document["news_spot_title"].text = "Neue Version verfügbar. Bitte neu laden."
        if _has_el("btn_news_spot"):
            document["btn_news_spot"].title = "Neue Version — Seite neu laden"
        if _has_el("btn_version_reload"):
            document["btn_version_reload"].classList.remove("hidden")
    else:
        header.classList.remove("is-update")
        if _has_el("news_spot_kicker"):
            document["news_spot_kicker"].text = "Aktuell"
        if _has_el("btn_news_spot"):
            document["btn_news_spot"].title = "Aktuelle Nachricht"
        if _has_el("btn_version_reload"):
            document["btn_version_reload"].classList.add("hidden")
        refresh_news_header()


def check_app_version(ev=None):
    if not storage.getItem("access_token"):
        return
    local = str(DEPLOY_VERSION or "").strip()
    if not local or local == "UNKNOWN":
        return

    def on_complete(req):
        if req.status != 200:
            return
        try:
            data = _parse_json(req) or {}
        except Exception:
            return
        remote = str(data.get("version") or "").strip()
        if not remote or remote == "UNKNOWN":
            return
        if remote != local:
            if not UPDATE_AVAILABLE:
                _set_update_banner(True)
        elif UPDATE_AVAILABLE:
            _set_update_banner(False)

    # Native XHR — no auth logout on this public endpoint.
    xhr = window.XMLHttpRequest.new()
    xhr.open("GET", f"/api/version?t={int(time.time())}", True)

    def _on_ready(ev2=None):
        if xhr.readyState != 4:
            return
        on_complete(_XhrShim(xhr))

    xhr.onreadystatechange = _on_ready
    xhr.send(None)


def start_version_poll():
    global VERSION_POLL_TIMER
    stop_version_poll()
    check_app_version()
    VERSION_POLL_TIMER = timer.set_interval(check_app_version, VERSION_POLL_MS)


def stop_version_poll():
    global VERSION_POLL_TIMER
    if VERSION_POLL_TIMER is not None:
        try:
            timer.clear_interval(VERSION_POLL_TIMER)
        except Exception:
            pass
        VERSION_POLL_TIMER = None


def _show_news_list_view():
    if _has_el("news_list"):
        document["news_list"].classList.remove("hidden")
    if _has_el("news_list_status"):
        document["news_list_status"].classList.remove("hidden")
    if _has_el("news_detail"):
        document["news_detail"].classList.add("hidden")


def _show_news_detail_view():
    if _has_el("news_list"):
        document["news_list"].classList.add("hidden")
    if _has_el("news_list_status"):
        document["news_list_status"].classList.add("hidden")
    if _has_el("news_detail"):
        document["news_detail"].classList.remove("hidden")


def open_news_detail(item):
    if not item:
        return
    news_id = item.get("id")
    _show_news_detail_view()
    document["news_detail_title"].text = str(item.get("title") or "")
    sev = str(item.get("severity") or "info")
    document["news_detail_meta"].text = (
        f"{_fmt_news_date(item.get('published_at'))} · {sev}"
    )
    body_el = document["news_detail_body"]
    body_md = str(item.get("body") or "")
    try:
        if hasattr(window, "marked") and window.marked is not None:
            html_out = window.marked.parse(body_md)
            body_el.innerHTML = html_out
        else:
            body_el.text = body_md
    except Exception:
        body_el.text = body_md

    def after_read(req=None):
        refresh_news_header()
        # Update local list flag
        for it in NEWS_ITEMS:
            if it.get("id") == news_id:
                it["is_read"] = True
                it["is_unread"] = False
                it["highlight"] = False

    if news_id and item.get("is_unread"):
        api_json(
            "POST",
            f"/api/desktop/news/{int(news_id)}/read",
            {},
            oncomplete=after_read,
        )
    else:
        after_read()


def _on_news_item_click(item):
    def handler(ev=None):
        open_news_detail(item)

    return handler


def render_news_list(items):
    global NEWS_ITEMS
    NEWS_ITEMS = items or []
    box = document["news_list"]
    box.clear()
    status = document["news_list_status"]
    if not NEWS_ITEMS:
        status.text = "Keine Nachrichten."
        status.classList.remove("hidden")
        return
    status.text = f"{len(NEWS_ITEMS)} Nachricht(en)"
    status.classList.remove("hidden")
    for item in NEWS_ITEMS:
        btn = html.BUTTON(Class="d-news-item")
        btn.attrs["type"] = "button"
        if item.get("is_unread"):
            btn.classList.add("is-unread")
        title = html.SPAN(str(item.get("title") or ""), Class="d-news-item-title")
        meta_bits = [_fmt_news_date(item.get("published_at"))]
        if item.get("is_unread"):
            meta_bits.append("ungelesen")
        else:
            meta_bits.append("gelesen")
        meta = html.SPAN(" · ".join(meta_bits), Class="d-news-item-meta")
        btn <= title
        btn <= meta
        btn.bind("click", _on_news_item_click(item))
        box <= btn


def load_news_page():
    _show_news_list_view()
    if _has_el("news_list_status"):
        document["news_list_status"].text = "Lade Nachrichten…"

    def on_complete(req):
        if req.status != 200:
            document["news_list_status"].text = "News konnten nicht geladen werden."
            return
        data = _parse_json(req) or {}
        items = data.get("items") or []
        render_news_list(items)
        _set_news_unread_badge(data.get("unread_count") or 0)

    api_json("GET", "/api/desktop/news", oncomplete=on_complete)


def back_to_news_list(ev=None):
    _show_news_list_view()
    load_news_page()


def on_news_spot_click(ev=None):
    if UPDATE_AVAILABLE:
        reload_for_new_version()
        return
    track(
        "UI_CLICK",
        label="Button: News-Spot",
        input={"button": "btn_news_spot", "news_id": NEWS_SPOT_ID},
    )
    goto_news()


def apply_expert_mode(enabled):
    global EXPERT_MODE
    EXPERT_MODE = bool(enabled)
    hint = document["expert_hint"]
    expert_bar = document["mgr_expert_bar"]
    if EXPERT_MODE:
        hint.classList.remove("hidden")
        expert_bar.classList.remove("hidden")
    else:
        hint.classList.add("hidden")
        expert_bar.classList.add("hidden")
    # Toggle per-row delete buttons if manager table is rendered
    try:
        for btn in document.select("#mgr_table .d-btn-danger"):
            if EXPERT_MODE:
                btn.classList.remove("hidden")
            else:
                btn.classList.add("hidden")
    except Exception:
        pass


def pruef_keys():
    if FIELD_META and FIELD_META.get("pruef_keys"):
        return FIELD_META["pruef_keys"]
    return PRUEF_KEYS_FALLBACK


def field_label(key):
    labels = (FIELD_META or {}).get("labels") or {}
    info = labels.get(key)
    if isinstance(info, list) and info:
        return info[0]
    return key


def field_placeholder(key):
    labels = (FIELD_META or {}).get("labels") or {}
    info = labels.get(key)
    if isinstance(info, list) and len(info) > 1:
        return info[1] or ""
    return ""


def ensure_form():
    global FORM_BUILT
    if FORM_BUILT:
        return
    fields = document["editor_fields"]
    fields.clear()

    # NiceGUI: left col mangel_number/room_name, right col floor/system_part_name
    cols = html.DIV(Class="d-field-row")
    col_l = html.DIV(Class="d-field-col")
    col_r = html.DIV(Class="d-field-col")
    for key in ("mangel_number", "room_name"):
        col_l <= _make_text_field(key, short=True)
    for key in ("floor", "system_part_name"):
        col_r <= _make_text_field(key, short=True)
    cols <= col_l
    cols <= col_r
    fields <= cols
    fields <= _make_text_field("error_source_description_note", short=False)

    bottom = document["editor_bottom"]
    bottom.clear()
    bottom <= _make_text_field("error_source_description_full", short=False, area=False)

    risk_opts = (FIELD_META or {}).get("risk_options") or [
        "Geringes Risiko",
        "Mittleres Risiko",
        "Hohes Risiko",
    ]
    prio_opts = (FIELD_META or {}).get("priority_options") or [
        "Geringe Priorität",
        "Mittlere Priorität",
        "Hohe Priorität",
    ]
    radios = html.DIV(Class="d-radio-row")
    radios <= _make_radio_group("risk", risk_opts)
    radios <= _make_radio_group("priority", prio_opts)
    bottom <= radios
    bottom <= _make_text_field("risk_description_full", short=False, area=True)
    bottom <= _make_text_field("actions_full", short=False, area=True)

    document["chk_extracted_norms"].bind("change", _on_pruef_checkbox("extracted_norms"))
    FORM_BUILT = True


def _on_pruef_checkbox(key):
    def handler(ev=None):
        checked = False
        el_id = f"chk_{key}"
        try:
            if _has_el(el_id):
                checked = bool(document[el_id].checked)
        except Exception:
            pass
        track(
            "CHECKBOX_CHANGE",
            label=f"Prüfhaken: {field_label(key)}",
            target_table="Mangel",
            target_id=ACTIVE_MANGEL_ID or 0,
            input={
                "field": key,
                "field_label": field_label(key),
                "checked": checked,
            },
        )
        update_pruef_progress()

    return handler


def _on_radio_change(key):
    def handler(ev=None):
        track(
            "RADIO_CHANGE",
            label=f"Auswahl: {field_label(key)}",
            target_table="Mangel",
            target_id=ACTIVE_MANGEL_ID or 0,
            input={"field": key, "value": get_radio_value(key)},
        )
        update_pruef_progress()

    return handler


def _autosize_textarea(el):
    """Keep at least 5 rows; grow with content."""
    if el is None:
        return
    try:
        el.style.height = "auto"
        min_h = 0
        try:
            # Prefer CSS min-height if available
            cs = window.getComputedStyle(el)
            min_h = int(float(str(cs.minHeight).replace("px", "") or "0"))
        except Exception:
            min_h = 0
        needed = int(el.scrollHeight or 0)
        el.style.height = str(max(needed, min_h)) + "px"
    except Exception:
        pass


def _on_textarea_input(ev=None):
    try:
        target = ev.target if ev is not None else None
        if target is not None:
            _autosize_textarea(target)
    except Exception:
        pass


def autosize_editor_textareas():
    for key in ("risk_description_full", "actions_full"):
        el_id = f"f_{key}"
        if _has_el(el_id):
            _autosize_textarea(document[el_id])


def _make_text_field(key, short=False, area=False):
    wrap = html.DIV(Class="d-field" + (" d-field-short" if short else ""))
    head = html.DIV(Class="d-field-head")
    chk = html.INPUT(type="checkbox", id=f"chk_{key}")
    chk.bind("change", _on_pruef_checkbox(key))
    head <= chk
    head <= html.LABEL(field_label(key), For=f"f_{key}")
    wrap <= head
    if area:
        inp = html.TEXTAREA(id=f"f_{key}", Class="d-input d-textarea")
        inp.attrs["rows"] = "5"
        inp.bind("input", _on_textarea_input)
    else:
        inp = html.INPUT(type="text", id=f"f_{key}", Class="d-input")
    ph = field_placeholder(key)
    if ph:
        inp.attrs["placeholder"] = "z.B.: " + ph
    wrap <= inp
    return wrap


def _make_radio_group(key, options):
    # NiceGUI: checkbox + inline radios (option labels carry the meaning)
    wrap = html.DIV(Class="d-field d-field-radio")
    chk = html.INPUT(type="checkbox", id=f"chk_{key}")
    chk.bind("change", _on_pruef_checkbox(key))
    wrap <= chk
    group = html.DIV(Class="d-radios", id=f"rg_{key}")
    for opt in options:
        lab = html.LABEL(Class="d-radio-label")
        rb = html.INPUT(type="radio", name=f"radio_{key}", value=opt)
        rb.bind("change", _on_radio_change(key))
        lab <= rb
        lab <= html.SPAN(opt)
        group <= lab
    wrap <= group
    return wrap


def update_pruef_progress():
    keys = pruef_keys()
    checked = 0
    for key in keys:
        el_id = f"chk_{key}"
        if _has_el(el_id) and document[el_id].checked:
            checked += 1
    total = len(keys)
    document["pruef_progress"].text = f"{checked}/{total}"
    bar = document["pruef_bar"]
    pct = int(round(100 * checked / total)) if total else 0
    bar.style.width = f"{pct}%"
    if total and checked >= total:
        bar.classList.add("is-complete")
    else:
        bar.classList.remove("is-complete")


def audio_url(mangel_id):
    token = storage.getItem("access_token") or ""
    mid = _as_int(mangel_id)
    return f"/api/desktop/maengel/{mid}/audio?token={token}&_={window.Date.new().getTime()}"


def _fmt_audio_time(sec):
    try:
        sec = float(sec or 0)
    except Exception:
        sec = 0
    if sec < 0 or sec != sec:  # NaN
        sec = 0
    m = int(sec // 60)
    s = int(sec % 60)
    return f"{m}:{s:02d}"


def _sync_audio_time_ui():
    player = document["audio_player"]
    seek = document["audio_seek"]
    try:
        cur = float(player.currentTime or 0)
    except Exception:
        cur = 0
    try:
        dur = float(player.duration or 0)
    except Exception:
        dur = 0
    if dur != dur or dur < 0:  # NaN
        dur = 0
    document["audio_time"].text = f"{_fmt_audio_time(cur)} / {_fmt_audio_time(dur)}"
    if not AUDIO_SEEK_DRAGGING:
        seek.max = dur if dur > 0 else 0
        seek.value = cur if dur > 0 else 0


def _set_audio_play_btn(playing):
    btn = document["btn_audio_play"]
    if playing:
        btn.text = "❚❚"
        btn.title = "Pause"
        btn.setAttribute("aria-label", "Pause")
    else:
        btn.text = "▶"
        btn.title = "Abspielen"
        btn.setAttribute("aria-label", "Abspielen")


def update_audio_ui(mangel=None):
    global AUDIO_SEEK_DRAGGING
    mangel = mangel if mangel is not None else LOADED_MANGEL
    wrap = document["audio_wrap"]
    player = document["audio_player"]
    has = bool((mangel or {}).get("has_audio")) and ACTIVE_MANGEL_ID is not None
    AUDIO_SEEK_DRAGGING = False
    if has:
        wrap.classList.remove("hidden")
        player.src = audio_url(ACTIVE_MANGEL_ID)
        _set_audio_play_btn(False)
        document["audio_time"].text = "0:00 / 0:00"
        document["audio_seek"].value = 0
        document["audio_seek"].max = 0
        try:
            player.volume = float(document["audio_volume"].value or 1)
        except Exception:
            pass
    else:
        wrap.classList.add("hidden")
        try:
            player.pause()
        except Exception:
            pass
        player.removeAttribute("src")
        try:
            player.load()
        except Exception:
            pass
        _set_audio_play_btn(False)


def toggle_audio_play(ev=None):
    player = document["audio_player"]
    src = player.getAttribute("src") or getattr(player, "src", "") or ""
    if not src:
        return
    try:
        if player.paused:
            track("AUDIO", label="Audio abspielen", input={"action": "play"})
            player.play()
            _set_audio_play_btn(True)
        else:
            track("AUDIO", label="Audio pause", input={"action": "pause"})
            player.pause()
            _set_audio_play_btn(False)
    except Exception:
        show_toast("Audio konnte nicht abgespielt werden")


def on_audio_seek_input(ev=None):
    global AUDIO_SEEK_DRAGGING
    seek = document["audio_seek"]
    AUDIO_SEEK_DRAGGING = True
    document["audio_time"].text = (
        f"{_fmt_audio_time(seek.value)} / {_fmt_audio_time(seek.max)}"
    )


def on_audio_seek_change(ev=None):
    global AUDIO_SEEK_DRAGGING
    player = document["audio_player"]
    seek = document["audio_seek"]
    AUDIO_SEEK_DRAGGING = False
    try:
        player.currentTime = float(seek.value or 0)
    except Exception:
        pass
    _sync_audio_time_ui()


def on_audio_volume(ev=None):
    try:
        document["audio_player"].volume = float(document["audio_volume"].value or 1)
    except Exception:
        pass


def bind_audio_controls():
    player = document["audio_player"]
    document["btn_audio_play"].bind("click", toggle_audio_play)
    document["audio_seek"].bind("input", on_audio_seek_input)
    document["audio_seek"].bind("change", on_audio_seek_change)
    document["audio_volume"].bind("input", on_audio_volume)

    def on_time(ev=None):
        _sync_audio_time_ui()

    def on_meta(ev=None):
        _sync_audio_time_ui()

    def on_ended(ev=None):
        _set_audio_play_btn(False)
        _sync_audio_time_ui()

    def on_play(ev=None):
        _set_audio_play_btn(True)

    def on_pause(ev=None):
        _set_audio_play_btn(False)

    player.ontimeupdate = on_time
    player.onloadedmetadata = on_meta
    player.ondurationchange = on_meta
    player.onended = on_ended
    player.onplay = on_play
    player.onpause = on_pause


def collect_edit_status():
    status = {}
    for key in pruef_keys():
        el_id = f"chk_{key}"
        if _has_el(el_id):
            status[key] = bool(document[el_id].checked)
    return status


def get_radio_value(key):
    group = document[f"rg_{key}"]
    for inp in group.select("input"):
        if inp.checked:
            return inp.value
    return None


def set_radio_value(key, value):
    group = document[f"rg_{key}"]
    for inp in group.select("input"):
        inp.checked = inp.value == (value or "")


def fill_form(mangel):
    mangel = mangel or {}
    edit_status = mangel.get("edit_status") or {}
    for key in TEXT_FIELDS:
        el_id = f"f_{key}"
        if _has_el(el_id):
            document[el_id].value = mangel.get(key) or ""
        chk_id = f"chk_{key}"
        if _has_el(chk_id):
            document[chk_id].checked = bool(edit_status.get(key))
    set_radio_value("risk", mangel.get("risk"))
    set_radio_value("priority", mangel.get("priority"))
    for key in ("risk", "priority"):
        chk_id = f"chk_{key}"
        if _has_el(chk_id):
            document[chk_id].checked = bool(edit_status.get(key))
    document["chk_extracted_norms"].checked = bool(edit_status.get("extracted_norms"))
    render_norms_chips(mangel.get("extracted_norms") or [])
    update_pruef_progress()
    update_audio_ui(mangel)
    autosize_editor_textareas()


def collect_payload():
    payload = {}
    for key in TEXT_FIELDS:
        el_id = f"f_{key}"
        if _has_el(el_id):
            payload[key] = document[el_id].value
    payload["risk"] = get_radio_value("risk")
    payload["priority"] = get_radio_value("priority")
    payload["edit_status"] = collect_edit_status()
    if LOADED_MANGEL is not None:
        payload["extracted_norms"] = LOADED_MANGEL.get("extracted_norms") or []
        payload["selected_images"] = LOADED_MANGEL.get("selected_images") or [0, 1]
    return payload


def render_norms_chips(extracted):
    box = document["norms_chips"]
    box.clear()
    copy_btn = document["btn_copy_norms"]
    grouped = {}
    if isinstance(extracted, list):
        for entry in extracted:
            if not isinstance(entry, dict):
                continue
            alias = str(entry.get("alias") or "").strip()
            if not alias:
                continue
            label = str(entry.get("citation_label") or "").strip()
            if not label and entry.get("page") not in (None, ""):
                label = f"S. {entry.get('page')}"
            grouped.setdefault(alias, [])
            if label and label not in grouped[alias]:
                grouped[alias].append(label)
    if not grouped:
        box <= html.SPAN("Keine Normen ausgewählt", Class="d-norms-empty")
        copy_btn.classList.add("hidden")
        copy_btn.attrs["data-copy"] = ""
        return
    copy_parts = []
    for name in sorted(grouped.keys()):
        labels = grouped[name]
        text = f"{name} ({', '.join(labels)})" if labels else name
        copy_parts.append(text)
        chip = html.SPAN(Class="d-chip")
        chip <= html.SPAN(name, Class="d-chip-name")
        if labels:
            chip <= html.SPAN("—", Class="d-chip-sep")
            chip <= html.SPAN(", ".join(labels), Class="d-chip-pages")
        box <= chip
    copy_btn.classList.remove("hidden")
    copy_btn.attrs["data-copy"] = ", ".join(copy_parts)


def copy_norms_to_clipboard(ev=None):
    text = document["btn_copy_norms"].attrs.get("data-copy") or ""
    if not text:
        show_toast("Nichts zu kopieren")
        return

    def on_ok(v=None):
        show_toast("Normen kopiert")

    def on_err(e=None):
        try:
            ta = html.TEXTAREA()
            ta.value = text
            document <= ta
            ta.select()
            document.execCommand("copy")
            ta.remove()
            show_toast("Normen kopiert")
        except Exception:
            show_toast("Kopieren fehlgeschlagen")

    try:
        prom = window.navigator.clipboard.writeText(text)
        if prom is not None and hasattr(prom, "then"):
            prom.then(on_ok).catch(on_err)
        else:
            on_ok()
    except Exception:
        on_err()


def show_editor(show):
    if show:
        _set_visible("editor_empty", False)
        _set_visible("editor_form", True)
    else:
        _set_visible("editor_empty", True)
        _set_visible("editor_form", False)
        update_audio_ui({})


def image_url(mangel_id, slot, index=None, prefer_crop=True):
    token = storage.getItem("access_token") or ""
    mid = _as_int(mangel_id)
    url = f"/api/desktop/maengel/{mid}/images/{slot}?token={token}"
    if index is not None:
        url += f"&index={_as_int(index)}"
    if not prefer_crop:
        url += "&prefer_crop=false"
    # cache bust
    url += f"&_={window.Date.new().getTime()}"
    return url


CROP_STATE = {
    "slot": 0,
    "index": 0,
    "img_w": 0,
    "img_h": 0,
    "max_w": 0,
    "max_h": 0,
    "min_w": 0,
    "min_h": 0,
    "x": 0,
    "y": 0,
    "w": 0,
    "h": 0,
    "image_el": None,
    "layout": None,
    "dragging": False,
    "drag_start_x": 0,
    "drag_start_y": 0,
    "drag_start_img_x": 0,
    "drag_start_img_y": 0,
    "drag_bound": False,
    "load_gen": 0,
    "annotate_snapshot": None,
}
CROP_RATIO = 4.0 / 3.0
CROP_ZOOM_STEP = 0.9  # each click multiplies size by this (in) or 1/this (out)
CROP_MIN_FRAC = 0.25  # smallest box = 25% of max 4:3 box


def load_gallery(mangel_id):
    def on_complete(req):
        if req.status != 200:
            return
        data = _parse_json(req)
        available = data.get("available") or []
        selected = data.get("selected_images") or [0, 1]
        if LOADED_MANGEL is not None:
            LOADED_MANGEL["selected_images"] = selected
        for slot in range(2):
            sel = document[f"gallery_sel_{slot}"]
            sel.clear()
            opts = available if available else list(range(max(2, (selected[slot] if slot < len(selected) else 0) + 1)))
            cur = selected[slot] if slot < len(selected) else slot
            for idx in opts:
                opt = html.OPTION(f"Aufnahme {int(idx) + 1}")
                opt.attrs["value"] = str(idx)
                if idx == cur:
                    opt.attrs["selected"] = "selected"
                sel <= opt
            if cur not in opts:
                opt = html.OPTION(f"Aufnahme {int(cur) + 1}")
                opt.attrs["value"] = str(cur)
                opt.attrs["selected"] = "selected"
                sel <= opt
            img = document[f"gallery_img_{slot}"]
            img.attrs["src"] = image_url(mangel_id, slot, cur)
            # enable/disable crop based on original existence
            slots = data.get("slots") or []
            has_orig = True
            for s in slots:
                if s.get("slot") == slot:
                    has_orig = bool(s.get("has_original") or s.get("exists"))
                    break
            document[f"btn_crop_{slot}"].disabled = not has_orig

    api_json("GET", f"/api/desktop/maengel/{mangel_id}/images", oncomplete=on_complete)


def on_gallery_change(slot):
    if ACTIVE_MANGEL_ID is None or LOADED_MANGEL is None:
        return
    sel = document[f"gallery_sel_{slot}"]
    try:
        idx = int(sel.value)
    except Exception:
        return
    selected = list(LOADED_MANGEL.get("selected_images") or [0, 1])
    while len(selected) < 2:
        selected.append(0)
    selected[slot] = idx
    LOADED_MANGEL["selected_images"] = selected
    document[f"gallery_img_{slot}"].attrs["src"] = image_url(ACTIVE_MANGEL_ID, slot, idx)

    def on_complete(req):
        if req.status == 200:
            show_toast(f"Abbildung {slot + 1} gesetzt")
        else:
            show_toast(f"Bildauswahl fehlgeschlagen ({req.status})")

    api_json(
        "PUT",
        f"/api/desktop/maengel/{ACTIVE_MANGEL_ID}/images/selection",
        {"selected_images": selected},
        oncomplete=on_complete,
    )


def _crop_max_box(img_w, img_h):
    img_w = max(1, int(img_w or 1))
    img_h = max(1, int(img_h or 1))
    if (img_w / img_h) >= CROP_RATIO:
        h = img_h
        w = int(round(h * CROP_RATIO))
    else:
        w = img_w
        h = int(round(w / CROP_RATIO))
    w = max(1, min(w, img_w))
    h = max(1, min(h, img_h))
    return w, h


def _crop_min_box(max_w, max_h):
    min_w = max(64, int(round(max_w * CROP_MIN_FRAC)))
    min_h = int(round(min_w / CROP_RATIO))
    if min_h < 48:
        min_h = 48
        min_w = int(round(min_h * CROP_RATIO))
    min_w = min(min_w, max_w)
    min_h = min(min_h, max_h)
    return max(1, min_w), max(1, min_h)


def _crop_clamp_box():
    img_w = max(1, int(CROP_STATE.get("img_w") or 1))
    img_h = max(1, int(CROP_STATE.get("img_h") or 1))
    max_w = max(1, int(CROP_STATE.get("max_w") or img_w))
    max_h = max(1, int(CROP_STATE.get("max_h") or img_h))
    min_w = max(1, int(CROP_STATE.get("min_w") or 1))
    min_h = max(1, int(CROP_STATE.get("min_h") or 1))
    w = int(CROP_STATE.get("w") or max_w)
    # Keep 4:3 from width; fall back to height if needed.
    w = max(min_w, min(w, max_w, img_w))
    h = int(round(w / CROP_RATIO))
    if h > img_h or h > max_h:
        h = min(max_h, img_h)
        w = int(round(h * CROP_RATIO))
        w = max(min_w, min(w, max_w, img_w))
        h = int(round(w / CROP_RATIO))
        h = max(min_h, min(h, max_h, img_h))
    x = int(CROP_STATE.get("x") or 0)
    y = int(CROP_STATE.get("y") or 0)
    x = max(0, min(x, img_w - w))
    y = max(0, min(y, img_h - h))
    CROP_STATE["x"] = x
    CROP_STATE["y"] = y
    CROP_STATE["w"] = w
    CROP_STATE["h"] = h
    return x, y, w, h


def _crop_update_meta():
    if not _has_el("crop_modal_meta"):
        return
    max_w = max(1, int(CROP_STATE.get("max_w") or 1))
    w = max(1, int(CROP_STATE.get("w") or 1))
    zoom_pct = int(round(100.0 * max_w / w))
    document["crop_modal_meta"].text = (
        f"{CROP_STATE.get('img_w')}×{CROP_STATE.get('img_h')} px · "
        f"Rahmen {CROP_STATE.get('w')}×{CROP_STATE.get('h')} · "
        f"Rahmen ziehen · Zoom {zoom_pct}%"
    )
    if _has_el("crop_zoom_label"):
        document["crop_zoom_label"].text = f"{zoom_pct}%"
    if _has_el("btn_crop_zoom_in"):
        document["btn_crop_zoom_in"].disabled = w <= int(CROP_STATE.get("min_w") or 0)
    if _has_el("btn_crop_zoom_out"):
        document["btn_crop_zoom_out"].disabled = w >= int(CROP_STATE.get("max_w") or 0)


def crop_zoom(factor):
    """factor < 1 → reinzoomen (kleinerer Ausschnitt), > 1 → rauszoomen."""
    if CROP_STATE.get("image_el") is None:
        return
    try:
        factor = float(factor)
    except Exception:
        return
    if factor <= 0:
        return
    x = int(CROP_STATE.get("x") or 0)
    y = int(CROP_STATE.get("y") or 0)
    w = max(1, int(CROP_STATE.get("w") or 1))
    h = max(1, int(CROP_STATE.get("h") or 1))
    cx = x + w / 2.0
    cy = y + h / 2.0
    new_w = int(round(w * factor))
    CROP_STATE["w"] = new_w
    CROP_STATE["h"] = int(round(new_w / CROP_RATIO))
    CROP_STATE["x"] = int(round(cx - CROP_STATE["w"] / 2.0))
    CROP_STATE["y"] = int(round(cy - CROP_STATE["h"] / 2.0))
    _crop_clamp_box()
    _crop_update_meta()
    draw_crop_canvas()


def crop_zoom_in(ev=None):
    crop_zoom(CROP_ZOOM_STEP)


def crop_zoom_out(ev=None):
    crop_zoom(1.0 / CROP_ZOOM_STEP)


def _crop_dom_canvas():
    """Raw HTMLCanvasElement — Brython wrappers can miss width/height bitmap resets."""
    try:
        el = window.document.getElementById("crop_canvas")
        if el is not None:
            return el
    except Exception:
        pass
    c = document["crop_canvas"]
    return getattr(c, "elt", c)


def _crop_clear_canvas_css(el):
    """CSS width/height on <canvas> stretches the bitmap — never keep them."""
    try:
        st = el.style
        for prop in ("width", "height", "max-width", "max-height", "aspect-ratio"):
            try:
                st.removeProperty(prop)
            except Exception:
                pass
    except Exception:
        pass


def _set_crop_canvas_bitmap(box_w, box_h, force_clear=False):
    """Set drawing-buffer size 1:1 with display; optionally wipe same-size ghosts."""
    el = _crop_dom_canvas()
    _crop_clear_canvas_css(el)
    box_w = max(1, int(box_w))
    box_h = max(1, int(box_h))
    try:
        cur_w = int(el.width or 0)
        cur_h = int(el.height or 0)
    except Exception:
        cur_w, cur_h = 0, 0
    if cur_w == box_w and cur_h == box_h:
        if force_clear:
            # Same-size assign does not clear — bump first to drop old pixels.
            el.width = 1
            el.height = 1
            el.width = box_w
            el.height = box_h
        return el
    el.width = box_w
    el.height = box_h
    try:
        el.setAttribute("width", str(box_w))
        el.setAttribute("height", str(box_h))
    except Exception:
        pass
    return el


def _recreate_crop_canvas(box_w=320, box_h=240):
    """Replace canvas node so no CSS/bitmap leftovers survive between opens."""
    wrap = document["crop_stage_wrap"]
    wrap.clear()
    canvas = html.CANVAS(width=int(box_w), height=int(box_h), Class="d-crop-canvas", id="crop_canvas")
    wrap <= canvas
    CROP_STATE["drag_bound"] = False
    bind_crop_canvas_drag()
    _crop_clear_canvas_css(_crop_dom_canvas())
    return document["crop_canvas"]


def _crop_stage_size(img_w, img_h):
    """Largest canvas that fits the viewport while matching the image aspect."""
    try:
        vw = float(window.innerWidth or 1024)
        vh = float(window.innerHeight or 768)
    except Exception:
        vw, vh = 1024.0, 768.0
    # Leave room for modal chrome (title, meta, zoom, save, padding).
    max_w = max(240.0, min(vw * 0.92 - 48.0, 1100.0))
    max_h = max(200.0, vh * 0.92 - 220.0)
    img_w = max(1.0, float(img_w or 1))
    img_h = max(1.0, float(img_h or 1))
    scale = min(max_w / img_w, max_h / img_h, 1.0)
    box_w = max(120, int(round(img_w * scale)))
    box_h = max(120, int(round(img_h * scale)))
    # Keep exact aspect (rounding can skew 1px).
    if img_w >= img_h:
        box_h = max(120, int(round(box_w * img_h / img_w)))
    else:
        box_w = max(120, int(round(box_h * img_w / img_h)))
    return box_w, box_h


def _crop_layout_metrics():
    """Image fills the canvas; canvas aspect matches the source image."""
    img_w = max(1, int(CROP_STATE.get("img_w") or 1))
    img_h = max(1, int(CROP_STATE.get("img_h") or 1))
    box_w, box_h = _crop_stage_size(img_w, img_h)
    scale = min(box_w / float(img_w), box_h / float(img_h))
    disp_w = img_w * scale
    disp_h = img_h * scale
    img_left = (box_w - disp_w) / 2.0
    img_top = (box_h - disp_h) / 2.0
    x = int(CROP_STATE.get("x") or 0)
    y = int(CROP_STATE.get("y") or 0)
    w = int(CROP_STATE.get("w") or 0)
    h = int(CROP_STATE.get("h") or 0)
    return {
        "box_w": box_w,
        "box_h": box_h,
        "scale": scale,
        "disp_w": disp_w,
        "disp_h": disp_h,
        "img_left": img_left,
        "img_top": img_top,
        "crop_left": img_left + x * scale,
        "crop_top": img_top + y * scale,
        "crop_w": w * scale,
        "crop_h": h * scale,
    }


def _crop_canvas_xy(ev):
    canvas = _crop_dom_canvas()
    rect = canvas.getBoundingClientRect()
    rw = float(rect.width or 0) or 1.0
    rh = float(rect.height or 0) or 1.0
    sx = float(canvas.width or 960) / rw
    sy = float(canvas.height or 720) / rh
    client_x = None
    client_y = None
    try:
        touches = getattr(ev, "touches", None)
        if touches is not None and getattr(touches, "length", 0):
            t = touches.item(0) if hasattr(touches, "item") else touches[0]
            client_x = float(t.clientX)
            client_y = float(t.clientY)
        else:
            changed = getattr(ev, "changedTouches", None)
            if changed is not None and getattr(changed, "length", 0):
                t = changed.item(0) if hasattr(changed, "item") else changed[0]
                client_x = float(t.clientX)
                client_y = float(t.clientY)
            else:
                client_x = float(ev.clientX)
                client_y = float(ev.clientY)
    except Exception:
        return None, None
    if client_x is None:
        return None, None
    return (client_x - float(rect.left)) * sx, (client_y - float(rect.top)) * sy


def _crop_point_in_frame(cx, cy, layout=None):
    L = layout or CROP_STATE.get("layout") or {}
    left = float(L.get("crop_left") or 0)
    top = float(L.get("crop_top") or 0)
    w = float(L.get("crop_w") or 0)
    h = float(L.get("crop_h") or 0)
    return left <= cx <= left + w and top <= cy <= top + h


def _crop_canvas_to_img(cx, cy, layout=None):
    L = layout or CROP_STATE.get("layout") or {}
    scale = float(L.get("scale") or 0) or 1.0
    return (
        (cx - float(L.get("img_left") or 0)) / scale,
        (cy - float(L.get("img_top") or 0)) / scale,
    )


def _crop_can_drag():
    img_w = int(CROP_STATE.get("img_w") or 0)
    img_h = int(CROP_STATE.get("img_h") or 0)
    w = int(CROP_STATE.get("w") or 0)
    h = int(CROP_STATE.get("h") or 0)
    return (img_w - w) > 0 or (img_h - h) > 0


def draw_crop_canvas(force_clear=False, show_guides=True):
    L = _crop_layout_metrics()
    CROP_STATE["layout"] = L
    box_w = int(L["box_w"])
    box_h = int(L["box_h"])
    canvas = _set_crop_canvas_bitmap(box_w, box_h, force_clear=force_clear)
    ctx = canvas.getContext("2d")
    ctx.setTransform(1, 0, 0, 1, 0, 0)
    ctx.clearRect(0, 0, box_w, box_h)
    ctx.fillStyle = "#111"
    ctx.fillRect(0, 0, box_w, box_h)

    img_el = CROP_STATE.get("image_el")
    if img_el is None:
        return

    img_left = L["img_left"]
    img_top = L["img_top"]
    disp_w = L["disp_w"]
    disp_h = L["disp_h"]
    try:
        ctx.drawImage(img_el, img_left, img_top, disp_w, disp_h)
    except Exception:
        pass

    if not show_guides:
        return

    crop_left = L["crop_left"]
    crop_top = L["crop_top"]
    crop_w = L["crop_w"]
    crop_h = L["crop_h"]
    ctx.strokeStyle = "#22c55e"
    ctx.lineWidth = 3
    ctx.strokeRect(crop_left, crop_top, crop_w, crop_h)
    # dim outside crop
    ctx.fillStyle = "rgba(0,0,0,0.35)"
    ctx.fillRect(img_left, img_top, disp_w, max(0, crop_top - img_top))
    ctx.fillRect(
        img_left,
        crop_top + crop_h,
        disp_w,
        max(0, img_top + disp_h - (crop_top + crop_h)),
    )
    ctx.fillRect(img_left, crop_top, max(0, crop_left - img_left), crop_h)
    ctx.fillRect(
        crop_left + crop_w,
        crop_top,
        max(0, img_left + disp_w - (crop_left + crop_w)),
        crop_h,
    )
    try:
        style_el = document["crop_canvas"]
        if CROP_STATE.get("dragging"):
            style_el.style.cursor = "grabbing"
        elif _crop_can_drag():
            style_el.style.cursor = "grab"
        else:
            style_el.style.cursor = "default"
    except Exception:
        pass


def build_annotate_snapshot():
    """Rasterize current crop box for Einzeichnungen — without UI guides.

    Prefer a clean crop from the original image (no green frame). Fall back to
    the crop canvas redrawn without guides.
    """
    w = max(1, int(CROP_STATE.get("w") or 0))
    h = max(1, int(CROP_STATE.get("h") or 0))
    meta_w = max(1, int(CROP_STATE.get("img_w") or 1))
    meta_h = max(1, int(CROP_STATE.get("img_h") or 1))
    x = max(0, int(CROP_STATE.get("x") or 0))
    y = max(0, int(CROP_STATE.get("y") or 0))

    # 1) Clean crop from original pixels (no green stroke / dimming).
    img_el = CROP_STATE.get("image_el")
    if img_el is not None:
        dom_img = getattr(img_el, "elt", img_el)
        try:
            nw = int(dom_img.naturalWidth or 0)
            nh = int(dom_img.naturalHeight or 0)
        except Exception:
            nw = nh = 0
        if nw < 1 or nh < 1:
            nw, nh = meta_w, meta_h
        if nw != meta_w or nh != meta_h:
            sx = int(round(x * nw / float(meta_w)))
            sy = int(round(y * nh / float(meta_h)))
            sw = max(1, int(round(w * nw / float(meta_w))))
            sh = max(1, int(round(h * nh / float(meta_h))))
        else:
            sx, sy, sw, sh = x, y, w, h
        if sx + sw > nw:
            sw = max(1, nw - sx)
        if sy + sh > nh:
            sh = max(1, nh - sy)
        try:
            fn = getattr(window, "__ddCropToDataUrl", None)
            if fn is not None:
                snap = fn(dom_img, sx, sy, sw, sh)
                if snap:
                    return str(snap)
        except Exception:
            pass

    # 2) Fallback: crop-canvas region without green guides.
    try:
        if CROP_STATE.get("image_el") is not None:
            draw_crop_canvas(show_guides=False)
        L = CROP_STATE.get("layout") or _crop_layout_metrics()
        src = _crop_dom_canvas()
        fn_canvas = getattr(window, "__ddCanvasRegionToDataUrl", None)
        cl = float(L.get("crop_left") or 0)
        ct = float(L.get("crop_top") or 0)
        cw = float(L.get("crop_w") or 0)
        ch = float(L.get("crop_h") or 0)
        snap = None
        if src is not None and fn_canvas is not None and cw >= 2 and ch >= 2:
            snap = fn_canvas(src, cl, ct, cw, ch, cw, ch)
        # Restore UI guides for the Zuschnitt tab.
        if CROP_STATE.get("image_el") is not None:
            draw_crop_canvas(show_guides=True)
        if snap:
            return str(snap)
    except Exception:
        try:
            if CROP_STATE.get("image_el") is not None:
                draw_crop_canvas(show_guides=True)
        except Exception:
            pass
    return None


def on_crop_canvas_down(ev=None):
    if CROP_STATE.get("image_el") is None:
        return
    if not _crop_can_drag():
        return
    cx, cy = _crop_canvas_xy(ev)
    if cx is None:
        return
    L = CROP_STATE.get("layout") or _crop_layout_metrics()
    if not _crop_point_in_frame(cx, cy, L):
        return
    ix, iy = _crop_canvas_to_img(cx, cy, L)
    CROP_STATE["dragging"] = True
    CROP_STATE["drag_start_x"] = int(CROP_STATE.get("x") or 0)
    CROP_STATE["drag_start_y"] = int(CROP_STATE.get("y") or 0)
    CROP_STATE["drag_start_img_x"] = ix
    CROP_STATE["drag_start_img_y"] = iy
    try:
        document["crop_canvas"].style.cursor = "grabbing"
        if hasattr(ev, "preventDefault"):
            ev.preventDefault()
    except Exception:
        pass


def on_crop_canvas_move(ev=None):
    cx, cy = _crop_canvas_xy(ev)
    if cx is None:
        return
    L = CROP_STATE.get("layout") or _crop_layout_metrics()
    if not CROP_STATE.get("dragging"):
        try:
            if _crop_point_in_frame(cx, cy, L) and _crop_can_drag():
                document["crop_canvas"].style.cursor = "grab"
            else:
                document["crop_canvas"].style.cursor = "default"
        except Exception:
            pass
        return

    ix, iy = _crop_canvas_to_img(cx, cy, L)
    dx = ix - float(CROP_STATE.get("drag_start_img_x") or 0)
    dy = iy - float(CROP_STATE.get("drag_start_img_y") or 0)
    CROP_STATE["x"] = int(round(int(CROP_STATE.get("drag_start_x") or 0) + dx))
    CROP_STATE["y"] = int(round(int(CROP_STATE.get("drag_start_y") or 0) + dy))
    _crop_clamp_box()
    draw_crop_canvas()
    try:
        if hasattr(ev, "preventDefault"):
            ev.preventDefault()
    except Exception:
        pass


def on_crop_canvas_up(ev=None):
    if not CROP_STATE.get("dragging"):
        return
    CROP_STATE["dragging"] = False
    try:
        document["crop_canvas"].style.cursor = "grab" if _crop_can_drag() else "default"
    except Exception:
        pass
    draw_crop_canvas()


def bind_crop_canvas_drag():
    if CROP_STATE.get("drag_bound"):
        return
    if not _has_el("crop_canvas"):
        return
    canvas = document["crop_canvas"]
    canvas.bind("mousedown", on_crop_canvas_down)
    canvas.bind("mousemove", on_crop_canvas_move)
    canvas.bind("mouseup", on_crop_canvas_up)
    canvas.bind("mouseleave", on_crop_canvas_up)
    canvas.bind("touchstart", on_crop_canvas_down)
    canvas.bind("touchmove", on_crop_canvas_move)
    canvas.bind("touchend", on_crop_canvas_up)
    CROP_STATE["drag_bound"] = True


def close_crop_modal(ev=None):
    document["crop_modal"].classList.add("hidden")
    CROP_STATE["load_gen"] = int(CROP_STATE.get("load_gen") or 0) + 1
    CROP_STATE["image_el"] = None
    CROP_STATE["dragging"] = False
    CROP_STATE["layout"] = None
    CROP_STATE["annotate_snapshot"] = None
    try:
        ann = getattr(window, "DD_ANNOTATE", None)
        if ann is not None:
            ann.dispose()
    except Exception:
        pass


def set_crop_modal_tab(tab):
    """tab: 'crop' | 'annotate'"""
    tab = "annotate" if tab == "annotate" else "crop"
    crop_btn = document["btn_crop_tab_crop"]
    ann_btn = document["btn_crop_tab_annotate"]
    crop_panel = document["crop_panel_crop"]
    ann_panel = document["crop_panel_annotate"]
    if tab == "annotate":
        crop_btn.classList.remove("is-active")
        ann_btn.classList.add("is-active")
        crop_btn.attrs["aria-selected"] = "false"
        ann_btn.attrs["aria-selected"] = "true"
        if CROP_STATE.get("image_el") is not None:
            draw_crop_canvas()
        crop_panel.classList.add("hidden")
        ann_panel.classList.remove("hidden")
        refresh_annotate_preview(from_live_crop=True)
    else:
        ann_btn.classList.remove("is-active")
        crop_btn.classList.add("is-active")
        ann_btn.attrs["aria-selected"] = "false"
        crop_btn.attrs["aria-selected"] = "true"
        ann_panel.classList.add("hidden")
        crop_panel.classList.remove("hidden")
        if CROP_STATE.get("image_el") is not None:
            draw_crop_canvas()


def refresh_annotate_preview(from_live_crop=True):
    """Load temp crop snapshot into Fabric annotation canvas."""
    if not _has_el("annotate_canvas"):
        return
    slot = int(CROP_STATE.get("slot") or 0)
    idx = int(CROP_STATE.get("index") or 0)
    snap = None
    if from_live_crop:
        snap = build_annotate_snapshot()
        CROP_STATE["annotate_snapshot"] = snap
    else:
        snap = CROP_STATE.get("annotate_snapshot")
    ann = getattr(window, "DD_ANNOTATE", None)
    if snap:
        try:
            if ann is not None:
                ann.init(snap)
        except Exception:
            pass
        if _has_el("annotate_hint"):
            document["annotate_hint"].text = (
                f"Abbildung {slot + 1} · Aufnahme {idx + 1} — Kästen, Kreise, Pfeile, Freihand "
                "(noch nicht gespeichert)."
            )
        return
    try:
        if ann is not None:
            ann.dispose()
    except Exception:
        pass
    if _has_el("annotate_hint"):
        document["annotate_hint"].text = "Kein Zuschnitt geladen."


def on_ann_tool_click(ev=None):
    try:
        mode = ev.target.getAttribute("data-ann-mode")
    except Exception:
        mode = None
    if not mode:
        return
    ann = getattr(window, "DD_ANNOTATE", None)
    if ann is not None:
        ann.setMode(mode)


def on_ann_swatch_click(ev=None):
    try:
        color = ev.target.getAttribute("data-ann-color")
    except Exception:
        color = None
    if not color:
        return
    ann = getattr(window, "DD_ANNOTATE", None)
    if ann is not None:
        ann.setColor(color)


def on_ann_delete(ev=None):
    ann = getattr(window, "DD_ANNOTATE", None)
    if ann is not None:
        ann.deleteSelected()


def on_ann_undo(ev=None):
    ann = getattr(window, "DD_ANNOTATE", None)
    if ann is not None:
        ann.undo()


def on_ann_clear(ev=None):
    ann = getattr(window, "DD_ANNOTATE", None)
    if ann is not None:
        ann.clearShapes()


def _annotate_export_jpeg():
    """Flattened JPEG data-URL from Fabric, or None if no shapes / no canvas."""
    try:
        ann = getattr(window, "DD_ANNOTATE", None)
        if ann is None:
            return None
        if not ann.hasShapes():
            return None
        return ann.exportJpeg()
    except Exception:
        return None


def save_crop(ev=None):
    if ACTIVE_MANGEL_ID is None:
        return
    x, y, w, h = (
        int(CROP_STATE.get("x") or 0),
        int(CROP_STATE.get("y") or 0),
        int(CROP_STATE.get("w") or 0),
        int(CROP_STATE.get("h") or 0),
    )
    if w <= 0 or h <= 0:
        show_toast("Ungültiger Zuschnitt")
        return
    idx = int(CROP_STATE.get("index") or 0)
    slot = int(CROP_STATE.get("slot") or 0)
    annotated = _annotate_export_jpeg()
    btn = document["btn_crop_save"]
    btn.disabled = True
    btn.text = "Speichern…"

    payload = {"index": idx, "x": x, "y": y, "w": w, "h": h}
    if annotated:
        payload["annotated_jpeg_base64"] = annotated

    def on_complete(req):
        btn.disabled = False
        btn.text = "Bearbeitung speichern"
        if req.status != 200:
            detail = "Speichern fehlgeschlagen"
            try:
                detail = _parse_json(req).get("detail") or detail
            except Exception:
                pass
            show_toast(str(detail))
            return
        show_toast("Bearbeitung gespeichert")
        CROP_STATE["annotate_snapshot"] = None
        try:
            ann = getattr(window, "DD_ANNOTATE", None)
            if ann is not None:
                ann.dispose()
        except Exception:
            pass
        close_crop_modal()
        # refresh gallery slot (prefer crop)
        document[f"gallery_img_{slot}"].attrs["src"] = image_url(
            ACTIVE_MANGEL_ID, slot, idx, prefer_crop=True
        )

    api_json(
        "POST",
        f"/api/desktop/maengel/{ACTIVE_MANGEL_ID}/images/crop",
        payload,
        oncomplete=on_complete,
    )


def open_crop_modal(slot):
    if ACTIVE_MANGEL_ID is None:
        show_toast("Kein Mangel geladen")
        return
    try:
        idx = int(document[f"gallery_sel_{slot}"].value)
    except Exception:
        show_toast("Keine Aufnahme gewählt")
        return

    track(
        "CROP",
        label="Bildbearbeitung öffnen",
        target_table="Mangel",
        target_id=ACTIVE_MANGEL_ID,
        input={"action": "open", "slot": int(slot), "index": idx},
    )
    CROP_STATE["slot"] = int(slot)
    CROP_STATE["index"] = idx
    CROP_STATE["dragging"] = False
    CROP_STATE["image_el"] = None
    CROP_STATE["layout"] = None
    CROP_STATE["load_gen"] = int(CROP_STATE.get("load_gen") or 0) + 1
    load_gen = CROP_STATE["load_gen"]
    CROP_STATE["annotate_snapshot"] = None
    document["crop_modal_title"].text = f"Bildbearbeitung — Abbildung {slot + 1}"
    document["crop_modal_meta"].text = "Lade Original…"
    document["crop_modal"].classList.remove("hidden")
    document["btn_crop_save"].disabled = True
    document["btn_crop_save"].text = "Bearbeitung speichern"
    if _has_el("btn_crop_zoom_in"):
        document["btn_crop_zoom_in"].disabled = True
    if _has_el("btn_crop_zoom_out"):
        document["btn_crop_zoom_out"].disabled = True
    if _has_el("crop_zoom_label"):
        document["crop_zoom_label"].text = "—"
    set_crop_modal_tab("crop")
    # Fresh canvas node — drops previous bitmap/CSS leftovers between images.
    _recreate_crop_canvas(320, 240)

    def on_meta(req):
        if load_gen != CROP_STATE.get("load_gen"):
            return
        if req.status != 200:
            document["crop_modal_meta"].text = f"Meta fehlgeschlagen ({req.status})"
            show_toast("Bild-Meta fehlgeschlagen")
            return
        meta = _parse_json(req)
        box = meta.get("default_box") or {}
        img_w = int(meta.get("img_w") or 0)
        img_h = int(meta.get("img_h") or 0)
        CROP_STATE["img_w"] = img_w
        CROP_STATE["img_h"] = img_h
        max_w, max_h = _crop_max_box(img_w, img_h)
        # Prefer server default when it matches 4:3 max fit.
        if int(box.get("w") or 0) > 0 and int(box.get("h") or 0) > 0:
            max_w = int(box.get("w"))
            max_h = int(box.get("h"))
        min_w, min_h = _crop_min_box(max_w, max_h)
        CROP_STATE["max_w"] = max_w
        CROP_STATE["max_h"] = max_h
        CROP_STATE["min_w"] = min_w
        CROP_STATE["min_h"] = min_h
        CROP_STATE["x"] = int(box.get("x") or 0)
        CROP_STATE["y"] = int(box.get("y") or 0)
        CROP_STATE["w"] = max_w
        CROP_STATE["h"] = max_h
        _crop_clamp_box()
        _crop_update_meta()

        try:
            img_el = window.Image.new()
        except Exception:
            img_el = html.IMG()

        def on_load(ev=None):
            if load_gen != CROP_STATE.get("load_gen"):
                return
            raw = getattr(img_el, "elt", img_el)
            CROP_STATE["image_el"] = raw
            _crop_clamp_box()
            _crop_update_meta()
            draw_crop_canvas(force_clear=True)
            document["btn_crop_save"].disabled = False

        def on_error(ev=None):
            if load_gen != CROP_STATE.get("load_gen"):
                return
            document["crop_modal_meta"].text = "Originalbild konnte nicht geladen werden"
            show_toast("Originalbild fehlt")

        img_el.onload = on_load
        img_el.onerror = on_error
        img_el.src = image_url(
            ACTIVE_MANGEL_ID, slot, idx, prefer_crop=False
        )

    api_json(
        "GET",
        f"/api/desktop/maengel/{ACTIVE_MANGEL_ID}/images/meta/{idx}",
        oncomplete=on_meta,
    )


def set_nav_mode(mode):
    global NAV_MODE
    prev = NAV_MODE
    NAV_MODE = mode if mode in ("customers", "maengel") else "customers"
    if NAV_MODE == "customers":
        document["btn_nav_customers"].classList.add("is-active")
        document["btn_nav_maengel"].classList.remove("is-active")
        load_customers_nav()
    else:
        document["btn_nav_maengel"].classList.add("is-active")
        document["btn_nav_customers"].classList.remove("is-active")
        load_maengel_nav()
    if prev != NAV_MODE:
        track(
            "NAV_MODE",
            label=f"Navigation: {'Projekte' if NAV_MODE == 'customers' else 'Mängel'}",
            input={"from": prev, "to": NAV_MODE},
        )


def _nav_icon(kind):
    return html.SPAN(Class=f"d-nav-icon d-nav-icon-{kind}")


def _fill_customer_projects(box, projects, customer):
    box.clear()
    if not projects:
        box <= html.P("Keine Projekte.", Class="d-meta d-nav-empty")
        return
    for p in projects:
        pid = p.get("id")
        active = pid == ACTIVE_PROJECT_ID
        cls = "d-nav-item d-nav-project" + (" is-active" if active else "")
        btn = html.BUTTON(Class=cls)
        btn <= _nav_icon("building")
        btn <= html.SPAN(p.get("label") or p.get("name") or str(pid), Class="d-nav-item-text")

        def make_select(project, cust):
            def select(ev2):
                select_project(project, cust)

            return select

        btn.bind("click", make_select(p, customer))
        box <= btn


def load_customers_nav():
    nav = document["nav_list"]
    nav.clear()
    nav <= html.P("Lade Projekte…", Class="d-meta d-nav-empty")

    def on_tree(req):
        nav.clear()
        if req.status != 200:
            nav <= html.P(f"Fehler ({req.status})", Class="d-error")
            return
        customers = _parse_json(req).get("customers") or []
        if not customers:
            nav <= html.P("Keine Kunden/Projekte.", Class="d-meta d-nav-empty")
            return

        any_open = False
        for c in customers:
            projects = c.get("projects") or []
            should_open = False
            if ACTIVE_CUSTOMER_ID is not None and c.get("id") == ACTIVE_CUSTOMER_ID:
                should_open = True
            elif ACTIVE_PROJECT_ID is not None:
                for p in projects:
                    if p.get("id") == ACTIVE_PROJECT_ID:
                        should_open = True
                        break

            block = html.DIV(Class="d-nav-customer" + (" is-open" if should_open else ""))
            title = html.BUTTON(Class="d-nav-customer-title")
            title.attrs["type"] = "button"
            title <= html.SPAN(Class="d-nav-chevron")
            title <= _nav_icon("work")
            title <= html.SPAN(
                c.get("name") or f"Kunde {c.get('id')}", Class="d-nav-customer-name"
            )
            projects_box = html.DIV(
                Class="d-nav-projects" + (" is-open" if should_open else "")
            )
            if should_open:
                _fill_customer_projects(projects_box, projects, c)
                any_open = True

            def make_toggle(blk, box, customer, projs):
                def toggle(ev):
                    open_now = not box.classList.contains("is-open")
                    if open_now:
                        blk.classList.add("is-open")
                        box.classList.add("is-open")
                        _fill_customer_projects(box, projs, customer)
                    else:
                        blk.classList.remove("is-open")
                        box.classList.remove("is-open")
                        box.clear()

                return toggle

            title.bind("click", make_toggle(block, projects_box, c, projects))
            block <= title
            block <= projects_box
            nav <= block

        # First visit: open first customer that has projects
        if not any_open:
            blocks = nav.select(".d-nav-customer")
            for i, c in enumerate(customers):
                if not (c.get("projects") or []):
                    continue
                if i >= len(blocks):
                    break
                blk = blocks[i]
                box = blk.select(".d-nav-projects")[0]
                blk.classList.add("is-open")
                box.classList.add("is-open")
                _fill_customer_projects(box, c.get("projects") or [], c)
                break

    api_json("GET", "/api/desktop/nav-tree", oncomplete=on_tree)


def load_maengel_nav():
    nav = document["nav_list"]
    nav.clear()
    if not ACTIVE_PROJECT_ID:
        nav <= html.P("Bitte zuerst ein Projekt wählen.", Class="d-meta d-nav-empty")
        return
    nav <= html.P("Lade Mängel…", Class="d-meta d-nav-empty")

    def on_complete(req):
        nav.clear()
        if req.status != 200:
            nav <= html.P(f"Fehler ({req.status})", Class="d-error")
            return
        data = _parse_json(req)
        items = data.get("maengel") or []
        progress = data.get("progress") or {}
        # soft-update progress in header without clearing names
        if progress:
            done = progress.get("done", 0)
            total = progress.get("total", 0)
            bar = document["ctx_progress_bar"]
            if total:
                pct = int(round(100 * done / total))
                document["ctx_progress"].text = f"{done} / {total} ({pct}%)"
                bar.style.width = f"{pct}%"
                if pct >= 100:
                    bar.classList.add("is-complete")
                else:
                    bar.classList.remove("is-complete")
            else:
                document["ctx_progress"].text = "0 / 0"
                bar.style.width = "0%"
                bar.classList.remove("is-complete")
        if not items:
            nav <= html.P("Keine Mängel in diesem Projekt.", Class="d-meta d-nav-empty")
            return
        for item in items:
            mid = _as_int(item.get("id"))
            done = item.get("done")
            cls = "d-nav-item d-nav-mangel"
            if done:
                cls += " is-done"
            if mid == ACTIVE_MANGEL_ID:
                cls += " is-active"
            btn = html.BUTTON(item.get("number") or str(mid), Class=cls)

            def make_open(mangel_id):
                def open_it(ev):
                    open_mangel(mangel_id)

                return open_it

            btn.bind("click", make_open(mid))
            nav <= btn

    api_json("GET", f"/api/desktop/projects/{_as_int(ACTIVE_PROJECT_ID)}/maengel", oncomplete=on_complete)


def open_mangel(mangel_id):
    global ACTIVE_MANGEL_ID, LOADED_MANGEL, ACTIVE_PROJECT_ID, ACTIVE_CUSTOMER_ID
    mangel_id = _as_int(mangel_id)

    def on_complete(req):
        global ACTIVE_MANGEL_ID, LOADED_MANGEL, ACTIVE_PROJECT_ID, ACTIVE_CUSTOMER_ID
        if req.status != 200:
            show_toast(f"Mangel laden fehlgeschlagen ({req.status})")
            return
        data = _parse_json(req)
        mangel = data.get("mangel") or {}
        LOADED_MANGEL = mangel
        ACTIVE_MANGEL_ID = _as_int(mangel.get("id"))
        project = data.get("project") or {}
        customer = data.get("customer") or {}
        if project.get("id"):
            ACTIVE_PROJECT_ID = _as_int(project.get("id"))
        if customer.get("id"):
            ACTIVE_CUSTOMER_ID = _as_int(customer.get("id"))
        ensure_form()
        fill_form(mangel)
        show_editor(True)
        # Update header names immediately from GET payload
        document["ctx_customer"].text = _dash((customer or {}).get("name"))
        if project:
            pname = project.get("name") or ""
            pnum = project.get("number")
            document["ctx_project"].text = (
                f"{pname} ({pnum})" if pnum not in (None, "") else _dash(pname)
            )
        load_context(mangel_id=ACTIVE_MANGEL_ID)
        load_gallery(ACTIVE_MANGEL_ID)
        if NAV_MODE == "maengel":
            load_maengel_nav()
        track(
            "CONTEXT_SELECT",
            label="Mangel geöffnet",
            target_table="Mangel",
            target_id=ACTIVE_MANGEL_ID,
            input={
                "kind": "mangel",
                "mangel_id": ACTIVE_MANGEL_ID,
                "project_id": ACTIVE_PROJECT_ID,
                "customer_id": ACTIVE_CUSTOMER_ID,
                "mangel_number": mangel.get("mangel_number"),
            },
        )

    api_json("GET", f"/api/desktop/maengel/{mangel_id}", oncomplete=on_complete)


def run_save(ev=None):
    if ACTIVE_MANGEL_ID is None:
        return
    track(
        "UI_CLICK",
        label="Button: Speichern",
        target_table="Mangel",
        target_id=ACTIVE_MANGEL_ID,
        input={"button": "btn_save"},
    )
    payload = collect_payload()

    def on_complete(req):
        global LOADED_MANGEL
        if req.status == 200:
            data = _parse_json(req)
            LOADED_MANGEL = data.get("mangel") or LOADED_MANGEL
            if LOADED_MANGEL:
                fill_form(LOADED_MANGEL)
            show_toast("Gespeichert")
            load_context(mangel_id=ACTIVE_MANGEL_ID)
            if NAV_MODE == "maengel":
                load_maengel_nav()
            return
        detail = "Speichern fehlgeschlagen"
        try:
            detail = _parse_json(req).get("detail") or detail
        except Exception:
            pass
        show_toast(str(detail))

    api_json("PUT", f"/api/desktop/maengel/{ACTIVE_MANGEL_ID}", payload, oncomplete=on_complete)


def run_delete(ev=None):
    if ACTIVE_MANGEL_ID is None:
        return
    if not window.confirm("Diesen Mangel wirklich löschen?"):
        return
    track(
        "UI_CLICK",
        label="Button: Löschen",
        target_table="Mangel",
        target_id=ACTIVE_MANGEL_ID,
        input={"button": "btn_delete"},
    )
    mid = ACTIVE_MANGEL_ID

    def on_complete(req):
        global ACTIVE_MANGEL_ID, LOADED_MANGEL
        if req.status == 200:
            ACTIVE_MANGEL_ID = None
            LOADED_MANGEL = None
            show_editor(False)
            show_toast("Mangel gelöscht")
            load_context(project_id=ACTIVE_PROJECT_ID)
            set_nav_mode("maengel")
            return
        show_toast(f"Löschen fehlgeschlagen ({req.status})")

    api_json("DELETE", f"/api/desktop/maengel/{mid}", oncomplete=on_complete)


def _set_cta_label(btn_id, text):
    label_id = btn_id + "_label"
    if _has_el(label_id):
        document[label_id].text = text
        return
    try:
        els = document[btn_id].select(".d-cta-fx-text")
        if els:
            els[0].text = text
            return
    except Exception:
        pass
    if _has_el(btn_id):
        document[btn_id].text = text


def run_generate(ev=None):
    if ACTIVE_MANGEL_ID is None:
        return
    track(
        "UI_CLICK",
        label="Button: Texte mit KI generieren",
        target_table="Mangel",
        target_id=ACTIVE_MANGEL_ID,
        input={"button": "btn_generate"},
    )
    btn = document["btn_generate"]
    btn.disabled = True
    _set_cta_label("btn_generate", "Generiere…")

    def on_complete(req):
        btn.disabled = False
        _set_cta_label("btn_generate", "Texte mit KI generieren")
        if req.status != 200:
            detail = "KI fehlgeschlagen"
            try:
                detail = _parse_json(req).get("detail") or detail
            except Exception:
                pass
            show_toast(str(detail))
            return
        fields = _parse_json(req).get("fields") or {}
        for key in (
            "error_source_description_full",
            "risk_description_full",
            "actions_full",
        ):
            if key in fields and _has_el(f"f_{key}"):
                document[f"f_{key}"].value = fields[key] or ""
                if LOADED_MANGEL is not None:
                    LOADED_MANGEL[key] = fields[key]
        autosize_editor_textareas()
        show_toast("KI fertig — bitte speichern")

    api_json(
        "POST",
        f"/api/desktop/maengel/{ACTIVE_MANGEL_ID}/generate-texts",
        {},
        oncomplete=on_complete,
    )


def load_field_meta(then=None):
    global FIELD_META

    def on_complete(req):
        global FIELD_META, FORM_BUILT
        if req.status == 200:
            FIELD_META = _parse_json(req)
            FORM_BUILT = False
        if then:
            then()

    api_json("GET", "/api/desktop/field-meta", oncomplete=on_complete)


def show_login():
    global ACTIVE_MANGEL_ID, LOADED_MANGEL, ACTIVE_PROJECT_ID, ACTIVE_CUSTOMER_ID
    global UPDATE_AVAILABLE
    ACTIVE_MANGEL_ID = None
    LOADED_MANGEL = None
    ACTIVE_PROJECT_ID = None
    ACTIVE_CUSTOMER_ID = None
    document.body.classList.remove("is-app")
    _set_visible("panel_login", True)
    _set_visible("panel_app", False)
    _set_visible("header_user", False)
    _set_visible("btn_logout", False)
    _set_visible("btn_settings", False)
    _set_visible("btn_feedback", False)
    _set_visible("news_header", False)
    _set_visible("context_bar", False)
    document["login_error"].classList.add("hidden")
    document["login_error"].text = ""
    clear_context()
    show_editor(False)
    stop_version_poll()
    UPDATE_AVAILABLE = False
    if _has_el("news_header"):
        document["news_header"].classList.remove("is-update")
    if _has_el("btn_version_reload"):
        document["btn_version_reload"].classList.add("hidden")


def show_app(email):
    document.body.classList.add("is-app")
    _set_visible("panel_login", False)
    _set_visible("panel_app", True)
    _set_visible("header_user", True)
    _set_visible("btn_logout", True)
    _set_visible("btn_settings", True)
    _set_visible("btn_feedback", True)
    _set_visible("news_header", True)
    _set_visible("context_bar", True)
    document["header_user"].text = str(email or "")
    document["version_label"].text = str(DEPLOY_VERSION or "UNKNOWN")
    set_tab(ACTIVE_TAB or "maengel")
    # Manager force-actions always available (header Expertenmodus removed)
    apply_expert_mode(True)
    refresh_news_header()
    start_version_poll()

    def after_meta():
        load_context()
        set_nav_mode("customers")

    load_field_meta(then=after_meta)


def check_auth():
    token = storage.getItem("access_token")
    if not token:
        show_login()
        return

    def on_complete(req):
        if req.status == 200:
            data = _parse_json(req)
            email = data.get("email") or ""
            if email:
                show_app(email)
                return
        storage.removeItem("access_token")
        show_login()
        if req.status not in (0, 401):
            show_toast(f"Auth-Check fehlgeschlagen ({req.status})")

    api_json("GET", "/api/desktop/me", oncomplete=on_complete)


def run_login(ev=None):
    email = (document["login_email"].value or "").strip()
    password = document["login_pass"].value or ""
    err = document["login_error"]
    err.classList.add("hidden")
    err.text = ""
    if not email or not password:
        err.text = "E-Mail und Passwort eingeben."
        err.classList.remove("hidden")
        return

    def on_complete(req):
        if req.status == 200:
            data = _parse_json(req)
            token = data.get("access_token")
            if token:
                storage.setItem("access_token", token)
                show_toast("Anmeldung erfolgreich")
                check_auth()
                return
        detail = "Login fehlgeschlagen"
        try:
            detail = _parse_json(req).get("detail") or detail
        except Exception:
            pass
        err.text = str(detail)
        err.classList.remove("hidden")

    ajax.post(
        "/api/login",
        data=json.dumps({"email": email, "password": password}),
        headers={"Content-Type": "application/json"},
        oncomplete=on_complete,
    )


def run_logout(ev=None):
    storage.removeItem("access_token")
    show_toast("Abgemeldet")
    show_login()


# ---------------------------------------------------------------------------
# Phase 5 — Normen Recherche
# ---------------------------------------------------------------------------


def ensure_research_ready():
    global HIDE_SHORT
    HIDE_SHORT = bool(document["chk_hide_short"].checked)
    load_stale_banner()
    bind_research_pdf_expand()


def set_research_pdf_expanded(expanded):
    if not _has_el("research_layout"):
        return
    layout = document["research_layout"]
    if expanded:
        layout.classList.add("is-pdf-expanded")
    else:
        layout.classList.remove("is-pdf-expanded")
    track(
        "PDF_EXPAND",
        label="PDF-Bereich " + ("erweitern" if expanded else "verkleinern"),
        input={"expanded": bool(expanded)},
    )
    # Wait for CSS grid transition, then fit PDF to the new pane width
    timer.set_timeout(pdf_js_fit_width, 230)


def pdf_js_fit_width(ev=None):
    if ACTIVE_TAB != "recherche":
        return
    if _has_el("tab_recherche"):
        cls = document["tab_recherche"].className or ""
        if "hidden" in cls.split():
            return

    def _done(scale=None):
        if scale is not None and _has_el("pdf_js_zoom_label"):
            try:
                document["pdf_js_zoom_label"].text = f"{int(round(float(scale) * 100))}%"
            except Exception:
                pass

    def _err(err=None):
        pass

    try:
        window.DD_PDFJS.fitToWidth().then(_done).catch(_err)
    except Exception:
        pass


def bind_research_pdf_expand():
    """Click in PDF pane expands (~75%); click in search/results restores default."""
    global RESEARCH_PDF_EXPAND_BOUND
    if RESEARCH_PDF_EXPAND_BOUND:
        return
    if not _has_el("research_pdf") or not _has_el("research_main"):
        return

    def on_pdf_click(ev=None):
        set_research_pdf_expanded(True)

    def on_main_click(ev=None):
        set_research_pdf_expanded(False)

    document["research_pdf"].bind("click", on_pdf_click)
    document["research_main"].bind("click", on_main_click)
    RESEARCH_PDF_EXPAND_BOUND = True


def load_stale_banner():
    global STALE_LOADED

    def on_complete(req):
        global STALE_LOADED
        STALE_LOADED = True
        banner = document["stale_banner"]
        if req.status != 200:
            banner.classList.add("hidden")
            return
        data = _parse_json(req)
        stale = int(data.get("stale_count") or 0)
        if stale > 0:
            banner.text = (
                f"Such-Index veraltet ({stale} PDF(s)). "
                "Im Normen Manager neu indexieren (Phase 6)."
            )
            banner.classList.remove("hidden")
        else:
            banner.classList.add("hidden")

    api_json("GET", "/api/desktop/search/index-status", oncomplete=on_complete)


def set_search_busy(busy, label=None):
    global SEARCH_BUSY
    SEARCH_BUSY = bool(busy)
    for bid in ("btn_search_sense", "btn_search_keyword"):
        document[bid].disabled = True if busy else False
    if _has_el("btn_search_more"):
        if busy:
            document["btn_search_more"].disabled = True
        else:
            document["btn_search_more"].disabled = bool(
                SEARCH_EXHAUSTED or not SEARCH_RESULTS or not SEARCH_QUERY
            )
    status = document["search_status"]
    if busy:
        status.text = label or "Suche läuft…"
    elif not SEARCH_RESULTS:
        status.text = ""


def reset_search_session(mode):
    global SEARCH_MODE, SEARCH_LIMIT, SEARCH_RESULTS, SEARCH_EXHAUSTED
    SEARCH_MODE = mode
    SEARCH_LIMIT = SEARCH_PAGE_SIZE
    SEARCH_RESULTS = []
    SEARCH_EXHAUSTED = False
    # Keep SELECTION across Sinngemäß/Wörtlich (NiceGUI states_dict parity)
    document["search_results"].clear()
    update_selected_bar()
    clear_pdf_previews()


def run_search(mode):
    global SEARCH_QUERY, SEARCH_MODE, SEARCH_LIMIT, SEARCH_RESULTS, SEARCH_EXHAUSTED
    query = (document["search_query"].value or "").strip()
    if not query:
        show_toast("Bitte einen Suchbegriff eingeben")
        return
    if SEARCH_BUSY:
        return
    SEARCH_QUERY = query
    reset_search_session(mode)
    SEARCH_MODE = mode
    SEARCH_LIMIT = SEARCH_PAGE_SIZE
    path = (
        "/api/desktop/search/semantic"
        if mode == "semantic"
        else "/api/desktop/search/keyword"
    )
    set_search_busy(True, "Sinngemäße Suche…" if mode == "semantic" else "Wörtliche Suche…")

    def on_complete(req):
        set_search_busy(False)
        if req.status != 200:
            detail = "Suche fehlgeschlagen"
            try:
                detail = _parse_json(req).get("detail") or detail
            except Exception:
                pass
            document["search_status"].text = str(detail)
            show_toast(str(detail))
            return
        data = _parse_json(req)
        global SEARCH_RESULTS, SEARCH_LIMIT, SEARCH_PAGE_SIZE, SEARCH_EXHAUSTED
        SEARCH_PAGE_SIZE = int(data.get("page_size") or 50)
        SEARCH_LIMIT = int(data.get("k") or SEARCH_PAGE_SIZE)
        SEARCH_RESULTS = list(data.get("results") or [])
        SEARCH_EXHAUSTED = False
        render_search_results()
        document["search_status"].text = f"{len(SEARCH_RESULTS)} Treffer"

    api_json(
        "POST",
        path,
        {"query": query, "k": SEARCH_LIMIT, "mode": mode},
        oncomplete=on_complete,
    )


def run_continue_search(ev=None):
    global SEARCH_LIMIT, SEARCH_RESULTS, SEARCH_EXHAUSTED
    query = (document["search_query"].value or SEARCH_QUERY or "").strip()
    if not query:
        show_toast("Bitte einen Suchbegriff eingeben")
        return
    if SEARCH_BUSY or not SEARCH_RESULTS:
        return
    prev_uids = {r.get("uid") for r in SEARCH_RESULTS}
    new_limit = int(SEARCH_LIMIT or SEARCH_PAGE_SIZE) + int(SEARCH_PAGE_SIZE or 50)
    path = (
        "/api/desktop/search/semantic"
        if SEARCH_MODE == "semantic"
        else "/api/desktop/search/keyword"
    )
    set_search_busy(True, "Weiter suchen…")

    def on_complete(req):
        global SEARCH_LIMIT, SEARCH_RESULTS, SEARCH_EXHAUSTED
        set_search_busy(False)
        if req.status != 200:
            detail = "Weiter suchen fehlgeschlagen"
            try:
                detail = _parse_json(req).get("detail") or detail
            except Exception:
                pass
            show_toast(str(detail))
            return
        data = _parse_json(req)
        SEARCH_LIMIT = int(data.get("k") or new_limit)
        answers = list(data.get("results") or [])
        new_only = [a for a in answers if a.get("uid") not in prev_uids]
        if not new_only:
            SEARCH_EXHAUSTED = True
            show_toast("Keine weiteren Treffer")
            render_search_results()
            return
        SEARCH_EXHAUSTED = False
        # Freeze+Append: keep previous order
        SEARCH_RESULTS = SEARCH_RESULTS + new_only
        render_search_results()
        document["search_status"].text = (
            f"{len(SEARCH_RESULTS)} Treffer (+{len(new_only)} neu)"
        )

    api_json(
        "POST",
        path,
        {"query": query, "k": new_limit, "mode": SEARCH_MODE},
        oncomplete=on_complete,
    )


def visible_results():
    out = []
    for r in SEARCH_RESULTS:
        if HIDE_SHORT and r.get("is_short"):
            continue
        out.append(r)
    return out


def render_search_results():
    box = document["search_results"]
    box.clear()
    rows = visible_results()
    if not SEARCH_RESULTS:
        box <= html.P("Noch keine Treffer.", Class="d-meta")
        update_selected_bar()
        return
    if not rows:
        box <= html.P(
            "Alle Treffer ausgeblendet (sehr kurze Quellen).",
            Class="d-meta",
        )
    else:
        for result in rows:
            box <= build_result_card(result)
    _append_search_more_control(box)
    update_selected_bar()


def _append_search_more_control(box):
    """NiceGUI: „Weiter suchen“ sits under the last source card."""
    wrap = html.DIV(Class="d-search-more-wrap")
    if SEARCH_EXHAUSTED:
        wrap <= html.P("Keine weiteren Treffer.", Class="d-meta d-search-more-exhausted")
        box <= wrap
        return
    if not SEARCH_RESULTS:
        return
    btn = html.BUTTON("Weiter suchen", Class="d-btn d-btn-outline", id="btn_search_more")
    btn.disabled = bool(SEARCH_BUSY or not SEARCH_QUERY)
    btn.bind("click", run_continue_search)
    wrap <= btn
    box <= wrap


def build_result_card(result):
    uid = result.get("uid")
    state = SELECTION.get(uid) or {
        "selected": False,
        "citation_mode": result.get("default_citation_mode") or "page",
        "manual_text": "",
        "result": result,
    }
    state["result"] = result
    SELECTION[uid] = state

    card = html.DIV(Class="d-result-card" + (" is-selected" if state["selected"] else ""))
    card.attrs["data-uid"] = uid

    head = html.DIV(Class="d-result-head")
    chk = html.INPUT(type="checkbox")
    chk.checked = bool(state["selected"])

    def on_sel(ev, u=uid, c=card, ch=chk, res=result):
        SELECTION[u]["selected"] = bool(ch.checked)
        if ch.checked:
            c.classList.add("is-selected")
        else:
            c.classList.remove("is-selected")
        update_selected_bar()
        r = (SELECTION.get(u) or {}).get("result") or res or {}
        p0 = int(r.get("page_start") or r.get("page") or 0)
        p1 = int(r.get("page_end") or p0)
        track(
            "RESULT_SELECT",
            label=(
                "Suchtreffer ausgewählt"
                if ch.checked
                else "Suchtreffer abgewählt"
            ),
            input={
                "uid": u,
                "selected": bool(ch.checked),
                "filename": r.get("filename"),
                "page": r.get("page") or p0,
                "page_start": p0,
                "page_end": p1,
                "page_label": r.get("page_label"),
                "alias": r.get("alias"),
                "title": r.get("title"),
                "score": r.get("score"),
                "citation_mode": (SELECTION.get(u) or {}).get("citation_mode")
                or r.get("default_citation_mode")
                or "page",
                "query": SEARCH_QUERY,
                "text_preview": (r.get("text") or "")[:200],
            },
        )

    chk.bind("change", on_sel)
    head <= chk
    head <= html.SPAN(result.get("alias") or "Quelle", Class="d-result-alias")
    head <= html.SPAN(
        f"({result.get('filename')}, {result.get('page_label')})",
        Class="d-result-file",
    )

    # PDF page buttons
    p0 = int(result.get("page_start") or 0)
    p1 = int(result.get("page_end") or p0)
    if p1 < p0:
        p1 = p0
    for page_no in range(p0, p1 + 1):
        if page_no <= 0:
            continue
        btn = html.BUTTON(f"PDF S. {page_no}", Class="d-btn d-btn-tiny d-btn-solid")

        def make_pdf(fn, pg, txt, p_start, p_end, source_uid, alias):
            def handler(ev):
                track(
                    "PDF_OPEN",
                    label=f"PDF-Seite öffnen S. {pg}",
                    input={
                        "filename": fn,
                        "page": pg,
                        "page_start": p_start,
                        "page_end": p_end,
                        "alias": alias,
                        "uid": source_uid,
                        "query": SEARCH_QUERY,
                        "text_preview": (txt or "")[:200],
                    },
                )
                load_pdf_preview(
                    fn,
                    pg,
                    txt,
                    page_start=p_start,
                    page_end=p_end,
                    source_uid=source_uid,
                )

            return handler

        btn.bind(
            "click",
            make_pdf(
                result.get("filename"),
                page_no,
                result.get("text") or "",
                p0,
                p1,
                uid,
                result.get("alias"),
            ),
        )
        head <= btn

    # Citation mode — dropdown shows full words; citation chips/DocX use S./A.
    head <= html.SPAN("Zitierweise:", Class="d-meta")
    sel = html.SELECT(Class="d-input d-cite-select")
    has_section = bool(result.get("has_section"))
    options = ["page", "section", "manual"] if has_section else ["page", "manual"]
    labels = {"page": "Seite", "section": "Abschnitt", "manual": "Manuell"}
    cur_mode = state.get("citation_mode") or result.get("default_citation_mode") or "page"
    if cur_mode not in options:
        cur_mode = "page"
        state["citation_mode"] = "page"
    for opt in options:
        o = html.OPTION(labels[opt])
        o.attrs["value"] = opt
        if opt == cur_mode:
            o.attrs["selected"] = "selected"
        sel <= o

    manual = html.INPUT(
        type="text",
        Class="d-input d-cite-manual",
        placeholder="z. B. Anlage 2 …",
    )
    manual.value = state.get("manual_text") or ""
    if cur_mode != "manual":
        manual.classList.add("hidden")

    def on_mode(ev, u=uid, s=sel, m=manual, res=result):
        mode = s.value
        SELECTION[u]["citation_mode"] = mode
        if mode == "manual":
            m.classList.remove("hidden")
        else:
            m.classList.add("hidden")
        update_selected_bar()
        track(
            "CITATION_MODE",
            label=f"Zitierweise: {mode}",
            input={
                "uid": u,
                "mode": mode,
                "alias": res.get("alias"),
                "filename": res.get("filename"),
            },
        )
    def on_manual(ev, u=uid, m=manual):
        SELECTION[u]["manual_text"] = m.value or ""
        SELECTION[u]["citation_mode"] = "manual"
        update_selected_bar()

    sel.bind("change", on_mode)
    manual.bind("input", on_manual)
    if not has_section:
        sel.attrs["title"] = "Keine Abschnittsnummer — Abschnitt nicht wählbar"
    head <= sel
    viewing = html.SPAN(Class="d-pdf-viewing-hint")
    viewing.attrs["data-uid"] = str(uid)
    if ACTIVE_PDF_UID is not None and str(ACTIVE_PDF_UID) == str(uid):
        viewing.text = "(wird rechts angezeigt)"
        viewing.classList.add("is-active")
        card.classList.add("is-pdf-viewing")
    head <= viewing
    head <= manual
    card <= head

    title_html = result.get("title_html") or ""
    if title_html:
        title_el = html.DIV(Class="d-result-title")
        title_el.html = title_html
        card <= title_el

    body = html.DIV(Class="d-result-body")
    expanded = {"on": False}
    body.html = result.get("text_preview_html") or ""
    card <= body
    if result.get("can_expand"):
        more = html.BUTTON("mehr anzeigen", Class="d-expand-btn")

        def toggle(ev, b=body, btn=more, r=result, st=expanded):
            st["on"] = not st["on"]
            if st["on"]:
                b.html = r.get("text_full_html") or ""
                btn.text = "weniger"
            else:
                b.html = r.get("text_preview_html") or ""
                btn.text = "mehr anzeigen"

        more.bind("click", toggle)
        card <= more

    return card


def citation_label_from_state(state):
    result = state.get("result") or {}
    mode = state.get("citation_mode") or result.get("default_citation_mode") or "page"
    if mode == "manual":
        text = str(state.get("manual_text") or "").strip()
        return text or "Manuell (ohne Text)"
    if mode == "section":
        section = result.get("section_number") or ""
        if section:
            return f"A. {section}"
        return result.get("page_label") or "S. ?"
    return result.get("page_label") or "S. ?"


def update_selected_bar():
    bar = document["selected_bar"]
    chips = document["selected_chips"]
    chips.clear()
    grouped = {}
    for uid, state in SELECTION.items():
        if not state.get("selected"):
            continue
        result = state.get("result") or {}
        alias = result.get("alias") or "Norm"
        label = citation_label_from_state(state)
        grouped.setdefault(alias, [])
        if label not in grouped[alias]:
            grouped[alias].append(label)
    if not grouped:
        bar.classList.add("hidden")
        return
    bar.classList.remove("hidden")
    for alias in sorted(grouped.keys()):
        labels = grouped[alias]
        text = f"{alias} ({', '.join(labels)})" if labels else alias
        chips <= html.SPAN(text, Class="d-chip")


def clear_selection(ev=None):
    track(
        "UI_CLICK",
        label="Auswahl leeren",
        input={"button": "btn_clear_selection", "selected_before": sum(1 for s in SELECTION.values() if s.get("selected"))},
    )
    for uid in list(SELECTION.keys()):
        SELECTION[uid]["selected"] = False
    render_search_results()


def set_active_pdf_uid(uid):
    """Mark which search-result card is currently shown in the PDF viewer."""
    global ACTIVE_PDF_UID
    ACTIVE_PDF_UID = uid
    refresh_pdf_viewing_hints()


def refresh_pdf_viewing_hints():
    if not _has_el("search_results"):
        return
    active = None if ACTIVE_PDF_UID is None else str(ACTIVE_PDF_UID)
    for card in document["search_results"].select(".d-result-card"):
        card.classList.remove("is-pdf-viewing")
        card_uid = card.attrs.get("data-uid")
        if active is not None and str(card_uid) == active:
            card.classList.add("is-pdf-viewing")
    for el in document["search_results"].select(".d-pdf-viewing-hint"):
        hint_uid = el.attrs.get("data-uid")
        if active is not None and str(hint_uid) == active:
            el.text = "(wird rechts angezeigt)"
            el.classList.add("is-active")
        else:
            el.text = ""
            el.classList.remove("is-active")


def load_pdf_preview(
    filename, page, highlight_text, page_start=None, page_end=None, source_uid=None
):
    """Show marked PDF page in the research sidebar (PDF.js).

    LEGACY: PNG image preview remains in ``_load_legacy_pdf_image_preview`` and
    can be re-enabled via ``USE_LEGACY_PDF_IMAGE_PREVIEW``.
    """
    p0 = _as_int(page_start if page_start is not None else page) or int(page)
    p1 = _as_int(page_end if page_end is not None else page) or p0
    if p1 < p0:
        p1 = p0
    set_active_pdf_uid(source_uid)
    if USE_LEGACY_PDF_IMAGE_PREVIEW:
        _load_legacy_pdf_image_preview(filename, page, highlight_text)
    _load_pdf_js_marked(filename, page, highlight_text, page_start=p0, page_end=p1)


def _load_legacy_pdf_image_preview(filename, page, highlight_text):
    """LEGACY — PNG data-URL preview (``POST /api/desktop/search/pdf-page``).

    Disabled by default (``USE_LEGACY_PDF_IMAGE_PREVIEW = False``). Keep for rollback.
    """
    if _has_el("pdf_legacy_pane"):
        document["pdf_legacy_pane"].classList.remove("hidden")
    document["pdf_preview_empty"].text = "Lade PDF…"
    document["pdf_preview_empty"].classList.remove("hidden")
    document["pdf_preview_img"].classList.add("hidden")

    def on_img_complete(req):
        if req.status != 200:
            document["pdf_preview_empty"].text = f"PDF-Fehler ({req.status})"
            show_toast("PDF-Vorschau fehlgeschlagen")
            return
        data = _parse_json(req)
        url = data.get("image_data_url")
        if not url:
            document["pdf_preview_empty"].text = "Kein Bild"
            return
        img = document["pdf_preview_img"]
        img.attrs["src"] = url
        img.classList.remove("hidden")
        document["pdf_preview_empty"].classList.add("hidden")

    api_json(
        "POST",
        "/api/desktop/search/pdf-page",
        {
            "filename": filename,
            "page": int(page),
            "highlight_text": highlight_text or "",
        },
        oncomplete=on_img_complete,
    )


def _load_pdf_js_marked(filename, page, highlight_text, page_start=None, page_end=None):
    """Active preview: full annotated PDF rendered with local PDF.js + page nav."""
    empty = document["pdf_js_empty"]
    viewer = document["pdf_js_viewer"]
    empty.text = "Lade PDF…"
    empty.classList.remove("hidden")
    viewer.classList.add("hidden")
    try:
        window.DD_PDFJS.clear()
    except Exception:
        pass

    open_page = int(page)
    cite_start = int(page_start if page_start is not None else open_page)
    cite_end = int(page_end if page_end is not None else open_page)
    payload = json.dumps(
        {
            "filename": filename,
            "page": open_page,
            "page_start": cite_start,
            "page_end": cite_end,
            "highlight_text": highlight_text or "",
        }
    )
    headers = dict(get_auth_header())
    headers["Content-Type"] = "application/json"

    xhr = window.XMLHttpRequest.new()
    xhr.open("POST", "/api/desktop/search/pdf-marked", True)
    xhr.responseType = "arraybuffer"
    for k, v in headers.items():
        xhr.setRequestHeader(k, v)

    def _on_ready(ev=None):
        if xhr.readyState != 4:
            return
        if xhr.status == 401:
            storage.removeItem("access_token")
            show_login()
            show_toast("Sitzung abgelaufen")
            return
        if xhr.status != 200:
            empty.text = f"PDF-Fehler ({xhr.status})"
            show_toast("PDF-Vorschau fehlgeschlagen")
            return

        # Prefer response headers; fall back to request params
        try:
            hdr_open = int(xhr.getResponseHeader("X-Marked-Source-Page") or open_page)
        except Exception:
            hdr_open = open_page
        try:
            hdr_cite0 = int(xhr.getResponseHeader("X-Cite-Page-Start") or cite_start)
        except Exception:
            hdr_cite0 = cite_start
        try:
            hdr_cite1 = int(xhr.getResponseHeader("X-Cite-Page-End") or cite_end)
        except Exception:
            hdr_cite1 = cite_end

        def _ok(val=None):
            empty.classList.add("hidden")
            viewer.classList.remove("hidden")
            timer.set_timeout(pdf_js_fit_width, 40)

        def _err(err=None):
            msg = "PDF-Anzeige fehlgeschlagen"
            try:
                msg = str(err) if err else msg
            except Exception:
                pass
            empty.text = msg
            empty.classList.remove("hidden")
            viewer.classList.add("hidden")

        try:
            promise = window.DD_PDFJS.showFromArrayBuffer(
                xhr.response, hdr_open, hdr_cite0, hdr_cite1
            )
            promise.then(_ok).catch(_err)
        except Exception as e:
            _err(e)

    xhr.onreadystatechange = _on_ready
    xhr.send(payload)


def pdf_js_zoom(delta):
    track(
        "PDF_ZOOM",
        label="PDF Zoom",
        input={"delta": float(delta)},
    )
    def _done(scale=None):
        if scale is not None and _has_el("pdf_js_zoom_label"):
            try:
                document["pdf_js_zoom_label"].text = f"{int(round(float(scale) * 100))}%"
            except Exception:
                pass

    def _err(err=None):
        show_toast("Zoom fehlgeschlagen")

    try:
        window.DD_PDFJS.zoomBy(float(delta)).then(_done).catch(_err)
    except Exception:
        show_toast("PDF-Zoom nicht verfügbar")


def pdf_js_prev_page(ev=None):
    track("PDF_PAGE", label="PDF Seite zurück", input={"direction": "prev"})
    def _done(page=None):
        pass

    def _err(err=None):
        show_toast("Seite wechseln fehlgeschlagen")

    try:
        window.DD_PDFJS.prevPage().then(_done).catch(_err)
    except Exception:
        show_toast("Seite wechseln nicht verfügbar")


def pdf_js_next_page(ev=None):
    track("PDF_PAGE", label="PDF Seite vor", input={"direction": "next"})
    def _done(page=None):
        pass

    def _err(err=None):
        show_toast("Seite wechseln fehlgeschlagen")

    try:
        window.DD_PDFJS.nextPage().then(_done).catch(_err)
    except Exception:
        show_toast("Seite wechseln nicht verfügbar")


def clear_pdf_previews():
    set_active_pdf_uid(None)
    if _has_el("pdf_preview_img"):
        document["pdf_preview_img"].classList.add("hidden")
    if _has_el("pdf_preview_empty"):
        document["pdf_preview_empty"].classList.remove("hidden")
        document["pdf_preview_empty"].text = "PDF-Vorschau (Seite wählen)"
    if _has_el("pdf_legacy_pane") and not USE_LEGACY_PDF_IMAGE_PREVIEW:
        document["pdf_legacy_pane"].classList.add("hidden")
    if _has_el("pdf_js_empty"):
        document["pdf_js_empty"].text = "PDF-Vorschau (Seite wählen)"
        document["pdf_js_empty"].classList.remove("hidden")
    if _has_el("pdf_js_viewer"):
        document["pdf_js_viewer"].classList.add("hidden")
    try:
        window.DD_PDFJS.clear()
    except Exception:
        pass


def confirm_selected_norms(ev=None):
    if ACTIVE_MANGEL_ID is None:
        show_toast("Bitte zuerst einen Mangel öffnen")
        return
    track(
        "UI_CLICK",
        label="Button: Normen übernehmen",
        target_table="Mangel",
        target_id=ACTIVE_MANGEL_ID,
        input={"button": "btn_confirm_norms", "query": SEARCH_QUERY},
    )
    norms = []
    for uid, state in SELECTION.items():
        if not state.get("selected"):
            continue
        result = state.get("result") or {}
        norms.append(
            {
                "alias": result.get("alias") or "",
                "text": result.get("text") or "",
                "filename": result.get("filename") or "",
                "page": result.get("page_start"),
                "unique_id": uid,
                "section_title": result.get("section_title"),
                "citation_mode": state.get("citation_mode")
                or result.get("default_citation_mode")
                or "page",
                "citation_label": citation_label_from_state(state),
            }
        )
    if not norms:
        show_toast("Keine Normen ausgewählt")
        return

    def on_complete(req):
        global LOADED_MANGEL
        if req.status != 200:
            detail = "Übernehmen fehlgeschlagen"
            try:
                detail = _parse_json(req).get("detail") or detail
            except Exception:
                pass
            show_toast(str(detail))
            return
        data = _parse_json(req)
        if LOADED_MANGEL is not None:
            LOADED_MANGEL["extracted_norms"] = data.get("extracted_norms") or norms
            if _has_el("norms_chips"):
                render_norms_chips(LOADED_MANGEL.get("extracted_norms") or [])
        show_toast(f"{len(norms)} Norm(en) übernommen")
        set_tab("maengel")

    api_json(
        "PUT",
        f"/api/desktop/maengel/{ACTIVE_MANGEL_ID}/selected-norms",
        {"norms": norms, "search_query": SEARCH_QUERY or document["search_query"].value},
        oncomplete=on_complete,
    )


def on_hide_short_change(ev=None):
    global HIDE_SHORT
    HIDE_SHORT = bool(document["chk_hide_short"].checked)
    track(
        "HIDE_SHORT",
        label="Kurze Treffer ausblenden",
        input={"checked": HIDE_SHORT, "query": SEARCH_QUERY},
    )
    if SEARCH_RESULTS:
        render_search_results()


# ---------------------------------------------------------------------------
# Phase 7 — DocX export
# ---------------------------------------------------------------------------


def _set_export_btn_label(busy=False):
    btn = document["btn_export_docx"]
    if busy:
        btn.html = "Export…"
    else:
        btn.html = '<span class="d-export-badge">DOCX</span>EXPORT (ALLE)'


def run_export_docx(force=False):
    if not ACTIVE_PROJECT_ID:
        show_toast("Bitte zuerst ein Projekt wählen")
        return
    track(
        "UI_CLICK",
        label="Button: DocX Export",
        target_table="Projekt",
        target_id=ACTIVE_PROJECT_ID,
        input={"button": "btn_export_docx", "force": bool(force)},
    )
    btn = document["btn_export_docx"]
    btn.disabled = True
    _set_export_btn_label(busy=True)

    headers = dict(get_auth_header())
    headers["Content-Type"] = "application/json"
    xhr = window.XMLHttpRequest.new()
    xhr.open("POST", "/api/desktop/export/docx", True)
    xhr.responseType = "blob"
    for k, v in headers.items():
        xhr.setRequestHeader(k, v)

    def _finish_ok(filename, blob):
        btn.disabled = False
        _set_export_btn_label(busy=False)
        url = window.URL.createObjectURL(blob)
        link = document.createElement("a")
        link.href = url
        link.download = filename or "bericht.docx"
        link.style.display = "none"
        document.body.appendChild(link)
        link.click()
        document.body.removeChild(link)
        window.URL.revokeObjectURL(url)
        show_toast("DocX erstellt")

    def _on_ready(ev=None):
        if xhr.readyState != 4:
            return
        if xhr.status == 401:
            btn.disabled = False
            _set_export_btn_label(busy=False)
            storage.removeItem("access_token")
            show_login()
            show_toast("Sitzung abgelaufen")
            return

        if xhr.status == 409:
            # Incomplete project — offer force export
            reader = window.FileReader.new()

            def on_load(ev2=None):
                btn.disabled = False
                _set_export_btn_label(busy=False)
                try:
                    detail = json.loads(reader.result or "{}")
                except Exception:
                    detail = {}
                if isinstance(detail, dict) and detail.get("detail"):
                    detail = detail.get("detail")
                if not isinstance(detail, dict):
                    detail = {}
                done = detail.get("done", "?")
                total = detail.get("total", "?")
                msg = (
                    f"Projekt unvollständig ({done}/{total} geprüft). "
                    "Trotzdem exportieren?"
                )
                if window.confirm(msg):
                    run_export_docx(force=True)
                else:
                    show_toast("Export abgebrochen")

            reader.onload = on_load
            reader.readAsText(xhr.response)
            return

        if xhr.status != 200:
            btn.disabled = False
            _set_export_btn_label(busy=False)
            reader = window.FileReader.new()

            def on_err(ev2=None):
                detail = "Export fehlgeschlagen"
                try:
                    data = json.loads(reader.result or "{}")
                    detail = data.get("detail") or detail
                    if isinstance(detail, dict):
                        detail = detail.get("message") or str(detail)
                except Exception:
                    pass
                show_toast(str(detail))

            reader.onload = on_err
            reader.readAsText(xhr.response)
            return

        # Success — filename from Content-Disposition if present
        filename = "bericht.docx"
        try:
            cd = xhr.getResponseHeader("content-disposition") or ""
            if "filename=" in cd:
                filename = cd.split("filename=")[-1].strip().strip('"')
        except Exception:
            pass
        _finish_ok(filename, xhr.response)

    xhr.onreadystatechange = _on_ready
    xhr.send(json.dumps({"project_id": int(ACTIVE_PROJECT_ID), "force": bool(force)}))


# ---------------------------------------------------------------------------
# Phase 6 — Normen Manager
# ---------------------------------------------------------------------------


def set_mgr_busy(busy, label=None):
    global MGR_BUSY
    MGR_BUSY = bool(busy)
    ids = (
        "btn_mgr_refresh",
        "btn_mgr_save_aliases",
        "btn_mgr_index_pending",
        "btn_mgr_search_refresh",
        "btn_mgr_force_reextract",
        "btn_mgr_force_rebuild",
        "btn_mgr_rebuild_chunks",
        "mgr_pdf_upload",
    )
    for bid in ids:
        if _has_el(bid):
            document[bid].disabled = bool(busy)
    status = document["mgr_status"]
    if busy:
        status.text = label or "Bitte warten…"
    elif not status.text or status.text.startswith("Bitte") or "läuft" in (status.text or ""):
        status.text = ""


def _mgr_job_title(job_key, fallback="Index-Job"):
    titles = {
        "force_reextract": "Force Reextract (Texte neu extrahieren)",
        "force_rebuild_search": "Force Rebuild (Such-Indizes)",
        "search_refresh": "Such-Index aktualisieren",
        "index_pending": "Pending indexieren",
        "force-reextract": "Force Reextract (Texte neu extrahieren)",
        "force-rebuild": "Force Rebuild (Such-Indizes)",
        "rebuild_chunks": "Neu aufbauen (schnell)",
        "rebuild-chunks": "Neu aufbauen (schnell)",
        "search-refresh": "Such-Index aktualisieren",
        "pending": "Pending indexieren",
    }
    return titles.get(job_key or "", fallback or "Index-Job")


def show_mgr_progress(visible=True):
    if not _has_el("mgr_progress"):
        return
    el = document["mgr_progress"]
    if visible:
        el.classList.remove("hidden")
    else:
        el.classList.add("hidden")
        el.classList.remove("is-aborted")
        el.classList.remove("is-stale")
        if _has_el("mgr_progress_alive"):
            document["mgr_progress_alive"].text = ""
            document["mgr_progress_alive"].classList.remove("is-live")
            document["mgr_progress_alive"].classList.remove("is-stale-text")


def _mgr_format_elapsed(seconds):
    try:
        sec = int(seconds)
    except Exception:
        return ""
    if sec < 0:
        sec = 0
    if sec < 60:
        return f"{sec}s"
    mins = sec // 60
    rem = sec % 60
    if mins < 60:
        return f"{mins}m {rem}s" if rem else f"{mins}m"
    hours = mins // 60
    mins = mins % 60
    return f"{hours}h {mins}m"


def update_mgr_progress_ui(progress=None, *, label=None, is_running=True):
    """Render live index-job progress in System-Einstellungen."""
    global MGR_LAST_PROGRESS_KEY, MGR_LAST_PROGRESS_CHANGE_AT, MGR_LAST_POLL_OK_AT
    if not _has_el("mgr_progress"):
        return
    progress = progress or {}
    show_mgr_progress(True)
    now_ms = int(window.Date.new().getTime())
    MGR_LAST_POLL_OK_AT = now_ms

    job = progress.get("job") or ""
    title = label or _mgr_job_title(job, MGR_JOB_LABEL or "Index-Job")
    document["mgr_progress_title"].text = title

    total = int(progress.get("total") or 0)
    current = int(progress.get("current_index") or 0)
    if current < 0:
        current = 0
    if total > 0 and current > total:
        current = total
    processed = int(progress.get("processed_count") or 0)
    failed = int(progress.get("failed_count") or 0)
    skipped = int(progress.get("skipped_count") or 0)
    finished = processed + failed + skipped
    # Prefer completed counts for remaining/%; fall back to current_index.
    done_for_bar = finished if finished > 0 else current
    if total > 0 and done_for_bar > total:
        done_for_bar = total
    remaining = max(total - finished, 0) if total else 0
    if total:
        pct = int(round(100.0 * done_for_bar / total))
    else:
        pct = 100 if not is_running else 0

    document["mgr_progress_pct"].text = f"{pct}%"
    bar = document["mgr_progress_bar"]
    bar.style.width = f"{pct}%"
    wrap = document["mgr_progress_bar_wrap"]
    wrap.setAttribute("aria-valuenow", str(pct))

    phase = (progress.get("phase") or "").strip()
    aborted = bool(progress.get("aborted") or phase == "aborted")
    panel = document["mgr_progress"]
    if aborted:
        panel.classList.add("is-aborted")
    else:
        panel.classList.remove("is-aborted")

    current_file = progress.get("current_file") or ""
    active = list(progress.get("active_files") or [])
    message = (progress.get("message") or "").strip()
    workers = progress.get("workers")
    elapsed_s = progress.get("elapsed_s")
    heartbeat = bool(progress.get("heartbeat"))
    updated_at = progress.get("updated_at") or ""

    progress_key = "|".join(
        [
            str(job),
            str(done_for_bar),
            str(total),
            str(processed),
            str(failed),
            str(skipped),
            str(current_file),
            str(message)[:80],
            str(elapsed_s),
            "1" if aborted else "0",
        ]
    )
    if progress_key != MGR_LAST_PROGRESS_KEY:
        MGR_LAST_PROGRESS_KEY = progress_key
        MGR_LAST_PROGRESS_CHANGE_AT = now_ms
    if not MGR_LAST_PROGRESS_CHANGE_AT:
        MGR_LAST_PROGRESS_CHANGE_AT = now_ms

    stale_ms = now_ms - MGR_LAST_PROGRESS_CHANGE_AT
    # File-count unchanged for a long time is normal during OCR, but warn after 3 min.
    is_stale = bool(is_running and not aborted and stale_ms >= 180000)
    if is_stale:
        panel.classList.add("is-stale")
    else:
        panel.classList.remove("is-stale")

    detail_bits = []
    if total:
        detail_bits.append(f"{done_for_bar}/{total} Dateien")
        detail_bits.append(f"noch {remaining}")
    if current_file:
        detail_bits.append(f"aktuell: {current_file}")
    elif active:
        shown = ", ".join(str(a) for a in active[:3])
        if len(active) > 3:
            shown += "…"
        detail_bits.append(f"aktiv: {shown}")
    if elapsed_s is not None and is_running and not aborted:
        detail_bits.append(f"seit {_mgr_format_elapsed(elapsed_s)} an dieser Datei")
    if workers:
        detail_bits.append(f"Worker: {workers}")
    if phase and phase not in ("running", "done"):
        detail_bits.append(f"Phase: {phase}")
    if message:
        msg = message if len(message) <= 220 else (message[:217] + "…")
        detail_bits.append(msg)
    document["mgr_progress_detail"].text = " · ".join(detail_bits) if detail_bits else (
        "Job startet…" if is_running else "Fertig"
    )

    document["mgr_progress_counts"].text = (
        f"OK {processed} · Fehler {failed} · übersprungen/offen {skipped}"
        + (f" · Rest {remaining}" if total else "")
    )

    if _has_el("mgr_progress_alive"):
        alive = document["mgr_progress_alive"]
        alive.classList.remove("is-live")
        alive.classList.remove("is-stale-text")
        if aborted:
            alive.text = "Job abgebrochen — siehe Meldung oben."
            alive.classList.add("is-stale-text")
        elif not is_running:
            alive.text = "Job beendet."
        elif is_stale:
            alive.text = (
                f"Datei-Zähler unverändert seit {_mgr_format_elapsed(stale_ms / 1000)} — "
                "oft normal bei großen PDFs/OCR. Backend-Logs prüfen, falls deutlich länger."
            )
            alive.classList.add("is-stale-text")
        else:
            tick = window.Date.new().toLocaleTimeString()
            hb = "Heartbeat aktiv" if heartbeat else "Status aktualisiert"
            extra = f" · Server: {updated_at}" if updated_at else ""
            alive.text = f"{hb} · GUI-Poll {tick}{extra}"
            alive.classList.add("is-live")

    if _has_el("mgr_status"):
        if aborted:
            document["mgr_status"].text = message or "Job abgebrochen"
        elif is_running:
            status_bits = [title]
            if total:
                status_bits.append(f"{done_for_bar}/{total} (noch {remaining})")
            if current_file:
                status_bits.append(str(current_file))
            if elapsed_s is not None:
                status_bits.append(f"seit {_mgr_format_elapsed(elapsed_s)}")
            document["mgr_status"].text = " · ".join(status_bits)
        else:
            document["mgr_status"].text = f"{title}: fertig ({processed} OK, {failed} Fehler)"


def stop_mgr_progress_poll():
    global MGR_PROGRESS_TIMER, MGR_LOCAL_TICK_TIMER, MGR_POLL_SEQ
    if MGR_PROGRESS_TIMER is not None:
        try:
            timer.clear_interval(MGR_PROGRESS_TIMER)
        except Exception:
            pass
        MGR_PROGRESS_TIMER = None
    if MGR_LOCAL_TICK_TIMER is not None:
        try:
            timer.clear_interval(MGR_LOCAL_TICK_TIMER)
        except Exception:
            pass
        MGR_LOCAL_TICK_TIMER = None
    MGR_POLL_SEQ = 0


def _mgr_apply_index_status(data, *, label=None):
    """Apply GET /api/desktop/index/status payload to the progress panel."""
    data = data or {}
    prog = data.get("progress") or {}
    running = bool(data.get("is_running"))
    update_mgr_progress_ui(
        prog,
        label=label or MGR_JOB_LABEL or _mgr_job_title(prog.get("job")),
        is_running=running or MGR_BUSY,
    )
    return running


def _mgr_local_tick(ev=None):
    """Update the alive line every second even between slow server polls."""
    if MGR_PROGRESS_TIMER is None or not _has_el("mgr_progress_alive"):
        return
    alive = document["mgr_progress_alive"]
    cls = alive.className or ""
    if "is-stale-text" in cls.split():
        return
    now_ms = int(window.Date.new().getTime())
    tick = window.Date.new().toLocaleTimeString()
    if MGR_LAST_POLL_OK_AT:
        ago_s = max(0, int((now_ms - MGR_LAST_POLL_OK_AT) / 1000))
        alive.text = f"Live-Poll aktiv · zuletzt vor {_mgr_format_elapsed(ago_s)} · {tick}"
    else:
        alive.text = f"Live-Poll startet… · {tick}"
    alive.classList.add("is-live")


def start_mgr_progress_poll(label=None):
    """Poll lightweight index status while a long index POST is in flight."""
    global MGR_PROGRESS_TIMER, MGR_LOCAL_TICK_TIMER, MGR_JOB_LABEL, MGR_POLL_SEQ
    global MGR_LAST_PROGRESS_KEY, MGR_LAST_PROGRESS_CHANGE_AT, MGR_LAST_POLL_OK_AT
    stop_mgr_progress_poll()
    if label:
        MGR_JOB_LABEL = label
    MGR_LAST_PROGRESS_KEY = ""
    MGR_LAST_PROGRESS_CHANGE_AT = int(window.Date.new().getTime())
    MGR_LAST_POLL_OK_AT = 0
    update_mgr_progress_ui(
        {"phase": "running", "total": 0, "current_index": 0},
        label=MGR_JOB_LABEL,
        is_running=True,
    )

    def _poll_once(ev=None):
        global MGR_POLL_SEQ
        if MGR_PROGRESS_TIMER is None:
            return
        try:
            MGR_POLL_SEQ += 1
            seq = MGR_POLL_SEQ

            def on_complete(req):
                if seq != MGR_POLL_SEQ or MGR_PROGRESS_TIMER is None:
                    return
                if req.status == 401:
                    return
                if req.status != 200:
                    if _has_el("mgr_progress_alive"):
                        document["mgr_progress_alive"].text = (
                            f"Status-Abfrage fehlgeschlagen (HTTP {req.status})"
                        )
                        document["mgr_progress_alive"].classList.add("is-stale-text")
                    return
                try:
                    data = _parse_json(req) or {}
                except Exception:
                    return
                _mgr_apply_index_status(data)

            api_json("GET", MGR_INDEX_STATUS_PATH, oncomplete=on_complete)
        except Exception as ex:
            if _has_el("mgr_progress_alive"):
                document["mgr_progress_alive"].text = f"Poll-Fehler: {ex}"
                document["mgr_progress_alive"].classList.add("is-stale-text")

    _poll_once()
    MGR_PROGRESS_TIMER = timer.set_interval(_poll_once, 1500)
    MGR_LOCAL_TICK_TIMER = timer.set_interval(_mgr_local_tick, 1000)


def run_index_job(path, confirm_msg=None, label="Index-Job…"):
    if MGR_BUSY:
        return
    if confirm_msg and not window.confirm(confirm_msg):
        return
    track(
        "UI_CLICK",
        label=f"Button: {label}",
        input={"button_path": path, "job_label": label},
    )
    set_mgr_busy(True, label)
    start_mgr_progress_poll(label=label)

    def on_complete(req):
        stop_mgr_progress_poll()
        set_mgr_busy(False)
        if req.status == 409:
            show_toast("Indexierung läuft bereits")
            show_mgr_progress(False)
            load_manager()
            return
        if req.status != 200:
            detail = "Job fehlgeschlagen"
            try:
                detail = _parse_json(req).get("detail") or detail
            except Exception:
                pass
            show_toast(str(detail))
            document["mgr_status"].text = str(detail)
            show_mgr_progress(False)
            load_manager()
            return
        data = _parse_json(req) or {}
        # Final progress from API summary / nested result.
        result = data.get("result") or {}
        final_prog = {
            "job": (result.get("mode") or ""),
            "phase": "aborted" if data.get("aborted") or data.get("status") == "aborted" else "done",
            "total": int(
                (result.get("targets") and len(result.get("targets") or []))
                or (
                    int(data.get("processed") or 0)
                    + int(data.get("failed") or 0)
                    + int(data.get("skipped") or 0)
                )
            ),
            "current_index": int(
                int(data.get("processed") or 0)
                + int(data.get("failed") or 0)
                + int(data.get("skipped") or 0)
            ),
            "processed_count": int(data.get("processed") or 0),
            "failed_count": int(data.get("failed") or 0),
            "skipped_count": int(data.get("skipped") or 0),
            "message": data.get("message") or result.get("abort_reason") or "",
            "aborted": bool(data.get("aborted") or data.get("status") == "aborted"),
            "workers": data.get("workers") or result.get("workers"),
        }
        if result.get("targets"):
            final_prog["total"] = len(result.get("targets") or [])
            final_prog["current_index"] = final_prog["total"]
        update_mgr_progress_ui(final_prog, label=label, is_running=False)

        if data.get("aborted") or data.get("status") == "aborted":
            show_toast(
                data.get("message")
                or (
                    "Extraktion abgebrochen (Worker-Crash). "
                    f"{data.get('processed', 0)} OK, "
                    f"{data.get('skipped', 0)} nicht verarbeitet — "
                    "bitte mit weniger Parallelität erneut versuchen."
                )
            )
        else:
            show_toast(
                f"Fertig: {data.get('processed', 0)} ok, "
                f"{data.get('failed', 0)} fehlgeschlagen, "
                f"{data.get('skipped', 0)} übersprungen"
            )
        load_manager()
        # Keep final progress visible after reload of table counts.
        update_mgr_progress_ui(final_prog, label=label, is_running=False)
        # Recherche stale banner neu laden
        global STALE_LOADED
        STALE_LOADED = False

    api_json("POST", path, {}, oncomplete=on_complete)


def load_manager(ev=None):
    if MGR_BUSY:
        return
    set_mgr_busy(True, "Lade PDF-Status…")

    def on_complete(req):
        set_mgr_busy(False)
        if req.status != 200:
            document["mgr_status"].text = f"Laden fehlgeschlagen ({req.status})"
            return
        data = _parse_json(req)
        render_manager(data)

    api_json("GET", "/api/desktop/pdfs", oncomplete=on_complete)


def render_manager(data):
    global MGR_PDFS, MGR_ALIAS_DRAFT
    data = data or {}
    MGR_PDFS = list(data.get("pdfs") or [])
    MGR_ALIAS_DRAFT = {}
    for row in MGR_PDFS:
        MGR_ALIAS_DRAFT[row.get("filename")] = row.get("alias") or ""

    counts = data.get("counts") or {}
    search = data.get("search_index") or {}
    counts_el = document["mgr_counts"]
    counts_el.clear()
    counts_el <= html.SPAN(f"PDFs: {counts.get('all', len(MGR_PDFS))}")
    counts_el <= html.SPAN(f"Indexiert: {counts.get('indexed', 0)}")
    counts_el <= html.SPAN(f"Pending: {counts.get('pending', 0)}")
    stale = int(search.get("stale_count") or 0)
    counts_el <= html.SPAN(f"Such-Index veraltet: {stale}")

    prog = data.get("progress") or {}
    if data.get("is_running"):
        job = prog.get("job") or ""
        poll_label = _mgr_job_title(job, MGR_JOB_LABEL or "Index-Job")
        update_mgr_progress_ui(prog, label=poll_label, is_running=True)
        # Resume polling after full page reload or if a previous poll loop died.
        if MGR_PROGRESS_TIMER is None:
            start_mgr_progress_poll(label=poll_label)
    else:
        # Keep a finished progress panel; only clear status when panel is hidden.
        progress_visible = False
        if _has_el("mgr_progress"):
            cls = document["mgr_progress"].className or ""
            progress_visible = "hidden" not in cls.split()
        if not progress_visible:
            document["mgr_status"].text = ""

    # Enable/disable action buttons based on counts
    document["btn_mgr_index_pending"].disabled = int(counts.get("pending") or 0) <= 0
    document["btn_mgr_search_refresh"].disabled = stale <= 0
    has_pdfs = len(MGR_PDFS) > 0
    document["btn_mgr_force_reextract"].disabled = not has_pdfs
    document["btn_mgr_force_rebuild"].disabled = not has_pdfs
    document["btn_mgr_rebuild_chunks"].disabled = not has_pdfs

    table = document["mgr_table"]
    table.clear()
    head = html.DIV(Class="d-mgr-row is-head")
    head <= html.SPAN("Datei")
    head <= html.SPAN("Alias")
    head <= html.SPAN("Index")
    head <= html.SPAN("Pending")
    head <= html.SPAN("")
    table <= head

    if not MGR_PDFS:
        empty = html.DIV(Class="d-mgr-row")
        empty <= html.SPAN("Keine PDFs im Profil.", Class="d-meta")
        table <= empty
        return

    for row in MGR_PDFS:
        fname = row.get("filename") or ""
        line = html.DIV(Class="d-mgr-row")
        line <= html.SPAN(fname)
        inp = html.INPUT(type="text", Class="d-input")
        inp.value = row.get("alias") or ""
        inp.attrs["data-filename"] = fname

        def make_alias_handler(name, el):
            def handler(ev):
                MGR_ALIAS_DRAFT[name] = el.value or ""

            return handler

        inp.bind("input", make_alias_handler(fname, inp))
        line <= inp
        if row.get("indexed"):
            line <= html.SPAN("ja", Class="d-badge-ok")
        else:
            line <= html.SPAN("nein", Class="d-meta")
        if row.get("pending"):
            line <= html.SPAN("ja", Class="d-badge-pending")
        else:
            line <= html.SPAN("nein", Class="d-meta")

        del_btn = html.BUTTON("Löschen", Class="d-btn d-btn-danger d-btn-tiny")
        if not EXPERT_MODE:
            del_btn.classList.add("hidden")

        def make_del(name):
            def handler(ev):
                delete_manager_pdf(name)

            return handler

        del_btn.bind("click", make_del(fname))
        line <= del_btn
        table <= line


def save_manager_aliases(ev=None):
    if MGR_BUSY:
        return
    # Collect from draft + any inputs
    aliases = dict(MGR_ALIAS_DRAFT)
    set_mgr_busy(True, "Speichere Aliases…")

    def on_complete(req):
        set_mgr_busy(False)
        if req.status != 200:
            detail = "Speichern fehlgeschlagen"
            try:
                detail = _parse_json(req).get("detail") or detail
            except Exception:
                pass
            show_toast(str(detail))
            return
        show_toast("Aliases gespeichert")
        load_manager()

    api_json("PUT", "/api/desktop/aliases", {"aliases": aliases}, oncomplete=on_complete)


def delete_manager_pdf(filename):
    if not EXPERT_MODE:
        show_toast("Löschen nur im Expertenmodus")
        return
    if MGR_BUSY:
        return
    if not window.confirm(f"PDF wirklich löschen?\n{filename}"):
        return
    set_mgr_busy(True, f"Lösche {filename}…")

    def on_complete(req):
        set_mgr_busy(False)
        if req.status != 200:
            detail = "Löschen fehlgeschlagen"
            try:
                detail = _parse_json(req).get("detail") or detail
            except Exception:
                pass
            show_toast(str(detail))
            load_manager()
            return
        show_toast(f"Gelöscht: {filename}")
        load_manager()

    # path may contain spaces — encode
    enc = window.encodeURIComponent(filename)
    api_json("DELETE", f"/api/desktop/pdfs/{enc}", oncomplete=on_complete)


def upload_manager_pdf(ev=None):
    if MGR_BUSY:
        return
    inp = document["mgr_pdf_upload"]
    files = getattr(inp, "files", None)
    if not files or files.length < 1:
        return
    file_obj = files.item(0)
    if not file_obj:
        return
    set_mgr_busy(True, "Upload…")
    fd = window.FormData.new()
    fd.append("file", file_obj)

    xhr = window.XMLHttpRequest.new()
    xhr.open("POST", "/api/desktop/pdfs", True)
    headers = get_auth_header()
    for k, v in headers.items():
        xhr.setRequestHeader(k, v)

    def _on_ready(ev2=None):
        if xhr.readyState != 4:
            return
        set_mgr_busy(False)
        inp.value = ""
        if xhr.status == 401:
            storage.removeItem("access_token")
            show_login()
            show_toast("Sitzung abgelaufen")
            return
        if xhr.status != 200:
            detail = "Upload fehlgeschlagen"
            try:
                detail = json.loads(xhr.responseText or "{}").get("detail") or detail
            except Exception:
                pass
            show_toast(str(detail))
            return
        show_toast("PDF hochgeladen")
        load_manager()

    xhr.onreadystatechange = _on_ready
    xhr.send(fd)


def open_feedback_modal(ev=None):
    if not _has_el("feedback_modal"):
        return
    document["feedback_message"].value = ""
    document["feedback_screenshot"].value = ""
    document["feedback_error"].classList.add("hidden")
    document["feedback_error"].text = ""
    document["btn_feedback_submit"].disabled = False
    document["btn_feedback_submit"].text = "Absenden"
    document["feedback_modal"].classList.remove("hidden")


def close_feedback_modal(ev=None):
    if not _has_el("feedback_modal"):
        return
    document["feedback_modal"].classList.add("hidden")


def _feedback_client_context(auto_screenshot=None, capture_method=None):
    ctx = {
        "active_tab": ACTIVE_TAB,
        "customer_id": ACTIVE_CUSTOMER_ID,
        "project_id": ACTIVE_PROJECT_ID,
        "mangel_id": ACTIVE_MANGEL_ID,
        "last_search_query": SEARCH_QUERY or "",
        "last_client_errors": list(CLIENT_ERRORS),
    }
    if auto_screenshot is not None:
        ctx["auto_screenshot"] = bool(auto_screenshot)
    if capture_method:
        ctx["capture_method"] = str(capture_method)
    return ctx


def _send_feedback_form(msg, file_obj, auto_shot, on_done, capture_method=None):
    """POST multipart feedback; on_done(ok, detail_or_none)."""
    form = window.FormData.new()
    form.append("message", msg)
    form.append(
        "client_context",
        json.dumps(
            _feedback_client_context(
                auto_screenshot=auto_shot,
                capture_method=capture_method,
            )
        ),
    )
    try:
        form.append("page_url", str(window.location.pathname or "/"))
    except Exception:
        form.append("page_url", "/")
    if file_obj is not None:
        # Manual upload or auto Blob
        try:
            # Blob from capture has no filename — give one for MIME sniffing.
            form.append("screenshot", file_obj, "feedback.jpg")
        except Exception:
            form.append("screenshot", file_obj)

    xhr = window.XMLHttpRequest.new()
    xhr.open("POST", "/api/feedback", True)
    headers = get_auth_header()
    for k, v in headers.items():
        xhr.setRequestHeader(k, v)

    def on_ready(ev2=None):
        if xhr.readyState != 4:
            return
        if xhr.status == 401:
            storage.removeItem("access_token")
            show_login()
            show_toast("Sitzung abgelaufen")
            on_done(False, "Sitzung abgelaufen")
            return
        if xhr.status in (200, 201):
            on_done(True, None)
            return
        detail = f"Senden fehlgeschlagen ({xhr.status})"
        try:
            detail = _parse_json(xhr).get("detail") or detail
            if isinstance(detail, list):
                detail = "; ".join(str(x) for x in detail)
        except Exception:
            pass
        on_done(False, str(detail))

    xhr.onreadystatechange = on_ready
    xhr.send(form)


def submit_feedback(ev=None):
    global FEEDBACK_BUSY
    if FEEDBACK_BUSY:
        return
    msg = (document["feedback_message"].value or "").strip()
    err = document["feedback_error"]
    err.classList.add("hidden")
    err.text = ""
    if len(msg) < 10:
        err.text = "Bitte mindestens 10 Zeichen beschreiben."
        err.classList.remove("hidden")
        return
    if len(msg) > 2000:
        err.text = "Nachricht zu lang (max. 2000 Zeichen)."
        err.classList.remove("hidden")
        return

    files = document["feedback_screenshot"].files
    manual_file = files.item(0) if files and files.length else None

    FEEDBACK_BUSY = True
    btn = document["btn_feedback_submit"]
    btn.disabled = True
    btn.text = "Sende…"

    # Hide dialog first so it never appears in the auto-screenshot.
    close_feedback_modal()

    def finish(ok, detail):
        global FEEDBACK_BUSY
        FEEDBACK_BUSY = False
        btn.disabled = False
        btn.text = "Absenden"
        if ok:
            show_toast("Danke, wir haben deine Meldung erhalten.")
            return
        # Re-open dialog with message preserved for retry.
        document["feedback_message"].value = msg
        document["feedback_modal"].classList.remove("hidden")
        err.text = detail or "Senden fehlgeschlagen"
        err.classList.remove("hidden")

    def send_with(file_obj, auto_shot, capture_method=None):
        btn.text = "Sende…"
        show_toast("Meldung wird gesendet…")
        _send_feedback_form(
            msg, file_obj, auto_shot, finish, capture_method=capture_method
        )

    if manual_file is not None:
        # Explicit upload replaces auto capture.
        send_with(manual_file, False, "upload")
        return

    # 1) Browser-Dialog: Tab/Fenster freigeben → echter Screenshot
    # 2) Abbruch/Fehler → html2canvas-Fallback (Screenshot bleibt Pflicht)
    show_toast("Bitte Tab oder Fenster freigeben…")

    def on_blob(blob):
        if blob is None:
            finish(False, "Screenshot fehlgeschlagen — bitte erneut absenden.")
            return
        method = "dom"
        try:
            method = str(window.DD_CAPTURE.lastMethod or "dom")
        except Exception:
            method = "dom"
        send_with(blob, True, method)

    def on_capture_err(err_obj=None):
        finish(False, "Screenshot fehlgeschlagen — bitte erneut absenden.")

    try:
        promise = window.DD_CAPTURE.captureJpegBlobPreferDisplay(0.85)
        promise.then(on_blob).catch(on_capture_err)
    except Exception:
        on_capture_err()

def main():
    _install_client_error_hooks()
    document["btn_login"].bind("click", run_login)
    document["btn_logout"].bind("click", run_logout)
    document["btn_settings"].bind("click", goto_manager)
    if _has_el("btn_feedback"):
        document["btn_feedback"].bind("click", open_feedback_modal)
        document["btn_feedback_close"].bind("click", close_feedback_modal)
        document["btn_feedback_submit"].bind("click", submit_feedback)

        def on_feedback_backdrop(ev):
            if ev.target is document["feedback_modal"]:
                close_feedback_modal()

        document["feedback_modal"].bind("click", on_feedback_backdrop)
    document["btn_manager_back"].bind("click", goto_maengel)
    document["btn_recherche_back"].bind("click", goto_maengel)
    if _has_el("btn_news"):
        document["btn_news"].bind("click", goto_news)
        document["btn_news_spot"].bind("click", on_news_spot_click)
        document["btn_news_back"].bind("click", goto_maengel)
        document["btn_news_detail_back"].bind("click", back_to_news_list)
    if _has_el("btn_version_reload"):
        document["btn_version_reload"].bind("click", reload_for_new_version)

    def on_visibility(ev=None):
        try:
            if document.visibilityState == "visible":
                check_app_version()
        except Exception:
            pass

    document.bind("visibilitychange", on_visibility)

    document["btn_nav_customers"].bind("click", lambda ev: set_nav_mode("customers"))
    document["btn_nav_maengel"].bind("click", lambda ev: set_nav_mode("maengel"))
    document["btn_save"].bind("click", run_save)
    document["btn_delete"].bind("click", run_delete)
    document["btn_generate"].bind("click", run_generate)
    bind_audio_controls()
    document["btn_goto_recherche"].bind("click", goto_recherche)
    document["btn_copy_norms"].bind("click", copy_norms_to_clipboard)
    document["btn_export_docx"].bind("click", lambda ev: run_export_docx(False))

    document["btn_search_sense"].bind("click", lambda ev: run_search("semantic"))
    document["btn_search_keyword"].bind("click", lambda ev: run_search("keyword"))
    document["chk_hide_short"].bind("change", on_hide_short_change)
    document["btn_confirm_norms"].bind("click", confirm_selected_norms)
    document["btn_clear_selection"].bind("click", clear_selection)
    bind_research_pdf_expand()
    if _has_el("btn_pdf_js_zoom_in"):
        document["btn_pdf_js_zoom_in"].bind("click", lambda ev: pdf_js_zoom(0.2))
    if _has_el("btn_pdf_js_zoom_out"):
        document["btn_pdf_js_zoom_out"].bind("click", lambda ev: pdf_js_zoom(-0.2))
    if _has_el("btn_pdf_js_prev"):
        document["btn_pdf_js_prev"].bind("click", pdf_js_prev_page)
    if _has_el("btn_pdf_js_next"):
        document["btn_pdf_js_next"].bind("click", pdf_js_next_page)

    document["btn_mgr_refresh"].bind("click", load_manager)
    document["btn_mgr_save_aliases"].bind("click", save_manager_aliases)
    document["btn_mgr_index_pending"].bind(
        "click",
        lambda ev: run_index_job(
            "/api/desktop/index/pending", label="Indexiere Pending…"
        ),
    )
    document["btn_mgr_search_refresh"].bind(
        "click",
        lambda ev: run_index_job(
            "/api/desktop/index/search-refresh", label="Aktualisiere Such-Index…"
        ),
    )
    document["btn_mgr_force_reextract"].bind(
        "click",
        lambda ev: run_index_job(
            "/api/desktop/index/force-reextract",
            confirm_msg="Alle PDFs neu extrahieren? Das kann lange dauern.",
            label="Force Reextract…",
        ),
    )
    document["btn_mgr_force_rebuild"].bind(
        "click",
        lambda ev: run_index_job(
            "/api/desktop/index/force-rebuild",
            confirm_msg="Such-Indizes neu aufbauen? Das kann lange dauern.",
            label="Force Rebuild…",
        ),
    )
    document["btn_mgr_rebuild_chunks"].bind(
        "click",
        lambda ev: run_index_job(
            "/api/desktop/index/rebuild-chunks",
            confirm_msg="Chunks neu aufbauen aus gecachtem Raw-MD? (kein OCR)",
            label="Neu aufbauen (schnell)…",
        ),
    )
    document["mgr_pdf_upload"].bind("change", upload_manager_pdf)

    def on_search_key(ev):
        if getattr(ev, "key", None) == "Enter" or getattr(ev, "keyCode", None) == 13:
            run_search("semantic")

    document["search_query"].bind("keydown", on_search_key)

    def make_gal(slot):
        def handler(ev):
            on_gallery_change(slot)

        return handler

    document["gallery_sel_0"].bind("change", make_gal(0))
    document["gallery_sel_1"].bind("change", make_gal(1))
    document["btn_crop_0"].bind("click", lambda ev: open_crop_modal(0))
    document["btn_crop_1"].bind("click", lambda ev: open_crop_modal(1))
    document["gallery_img_0"].bind("click", lambda ev: open_crop_modal(0))
    document["gallery_img_1"].bind("click", lambda ev: open_crop_modal(1))
    document["btn_crop_close"].bind("click", close_crop_modal)
    document["btn_crop_save"].bind("click", save_crop)
    if _has_el("btn_crop_zoom_in"):
        document["btn_crop_zoom_in"].bind("click", crop_zoom_in)
    if _has_el("btn_crop_zoom_out"):
        document["btn_crop_zoom_out"].bind("click", crop_zoom_out)
    if _has_el("btn_crop_tab_crop"):
        document["btn_crop_tab_crop"].bind("click", lambda ev: set_crop_modal_tab("crop"))
    if _has_el("btn_crop_tab_annotate"):
        document["btn_crop_tab_annotate"].bind(
            "click", lambda ev: set_crop_modal_tab("annotate")
        )
    for tool_id in (
        "btn_ann_select",
        "btn_ann_rect",
        "btn_ann_ellipse",
        "btn_ann_arrow",
        "btn_ann_free",
    ):
        if _has_el(tool_id):
            document[tool_id].bind("click", on_ann_tool_click)
    try:
        for sw in document.select(".d-ann-swatch"):
            sw.bind("click", on_ann_swatch_click)
    except Exception:
        pass
    if _has_el("btn_ann_delete"):
        document["btn_ann_delete"].bind("click", on_ann_delete)
    if _has_el("btn_ann_undo"):
        document["btn_ann_undo"].bind("click", on_ann_undo)
    if _has_el("btn_ann_clear"):
        document["btn_ann_clear"].bind("click", on_ann_clear)

    # Click backdrop to close
    def on_modal_backdrop(ev):
        if ev.target is document["crop_modal"]:
            close_crop_modal()

    document["crop_modal"].bind("click", on_modal_backdrop)

    def on_login_key(ev):
        if getattr(ev, "key", None) == "Enter" or getattr(ev, "keyCode", None) == 13:
            run_login()

    document["login_pass"].bind("keydown", on_login_key)
    check_auth()


main()
