from __future__ import annotations

import base64
import contextlib
import hashlib
import hmac
import importlib.util
import io
import json
import os
import re
import shutil
import subprocess
import sys
import threading
import time
import zipfile
from datetime import datetime
from pathlib import Path
from typing import Any, Callable, Optional

import requests
import streamlit as st

import ask
import build_index
import chunk_note


APP_NAME = "ATHENODE"
PROJECT_DIR = Path(__file__).resolve().parent
FETCH_SCRIPT = PROJECT_DIR / "fetch_text_from_selected_onenote_url.py"
AUTH_API_BASE_ENV = (os.getenv("EKSAMENAPP_AUTH_API_BASE") or "").strip().rstrip("/")
AUTH_API_BASE_FIXED = "https://backend-production-ba87e.up.railway.app"
AUTH_API_BASE_RUNTIME = AUTH_API_BASE_ENV or AUTH_API_BASE_FIXED
DEFAULT_CHECKOUT_SUCCESS_URL = (os.getenv("EKSAMENAPP_CHECKOUT_SUCCESS_URL") or "https://example.com/success").strip()
DEFAULT_CHECKOUT_CANCEL_URL = (os.getenv("EKSAMENAPP_CHECKOUT_CANCEL_URL") or "https://example.com/cancel").strip()
DEFAULT_CHECKOUT_PRICE_DKK = int((os.getenv("EKSAMENAPP_CHECKOUT_PRICE_DKK") or "39").strip() or "39")
SCHOOL_PRESETS: dict[str, dict[str, str]] = {
    "Favrskov Gymnasium": {
        "client_id": "ae6a7f01-a688-4fac-9270-90887ace678a",
        "tenant_id": "47e3f7c0-3633-4d6c-8d45-96af00859ff0",
    }
}


def _default_app_data_dir() -> Path:
    override = (os.getenv("EKSAMENAPP_DATA_DIR") or "").strip()
    if override:
        return Path(override).expanduser().resolve()

    if sys.platform == "darwin":
        return Path.home() / "Library" / "Application Support" / APP_NAME
    if sys.platform.startswith("win"):
        base = os.getenv("APPDATA")
        if base:
            return Path(base) / APP_NAME
        return Path.home() / "AppData" / "Roaming" / APP_NAME
    return Path.home() / ".local" / "share" / APP_NAME


APP_DATA_DIR = _default_app_data_dir()
LIBRARIES_DIR = APP_DATA_DIR / "libraries"
EXPORTS_DIR = APP_DATA_DIR / "exports"
AUTH_DIR = APP_DATA_DIR / "auth"
SETTINGS_PATH = APP_DATA_DIR / "settings.json"
USERS_PATH = APP_DATA_DIR / "users.json"
APP_VERSION_FILE = PROJECT_DIR / "VERSION"
APP_VERSION_ENV = (os.getenv("EKSAMENAPP_APP_VERSION") or "").strip()
try:
    UPDATE_CHECK_TTL_SEC = max(30, int((os.getenv("EKSAMENAPP_UPDATE_CHECK_TTL_SEC") or "300").strip() or "300"))
except Exception:
    UPDATE_CHECK_TTL_SEC = 300


def _env_bool(name: str, default: bool = False) -> bool:
    raw = os.getenv(name)
    if raw is None:
        return default
    return str(raw).strip().lower() in {"1", "true", "yes", "on"}


def _env_int(name: str, default: int, *, min_value: int, max_value: int) -> int:
    try:
        value = int((os.getenv(name) or str(default)).strip() or str(default))
    except Exception:
        value = default
    return max(min_value, min(max_value, value))


def _env_float(name: str, default: float, *, min_value: float, max_value: float) -> float:
    try:
        value = float((os.getenv(name) or str(default)).strip() or str(default))
    except Exception:
        value = default
    return max(min_value, min(max_value, value))


EXIT_ON_NO_BROWSER = _env_bool("EKSAMENAPP_EXIT_ON_NO_BROWSER", default=False)
EXIT_IDLE_GRACE_SEC = _env_int("EKSAMENAPP_EXIT_IDLE_GRACE_SEC", 45, min_value=5, max_value=1800)
EXIT_STARTUP_GRACE_SEC = _env_int("EKSAMENAPP_EXIT_STARTUP_GRACE_SEC", 120, min_value=15, max_value=3600)
EXIT_POLL_SEC = _env_float("EKSAMENAPP_EXIT_POLL_SEC", 1.0, min_value=0.2, max_value=10.0)


def _ensure_app_dirs() -> None:
    APP_DATA_DIR.mkdir(parents=True, exist_ok=True)
    LIBRARIES_DIR.mkdir(parents=True, exist_ok=True)
    EXPORTS_DIR.mkdir(parents=True, exist_ok=True)
    AUTH_DIR.mkdir(parents=True, exist_ok=True)


def _default_settings() -> dict[str, Any]:
    default_school = next(iter(SCHOOL_PRESETS.keys()), "Favrskov Gymnasium")
    preset = SCHOOL_PRESETS.get(default_school, {})
    return {
        "retention_days": 30,
        "telemetry_enabled": False,
        "auth_api_base": AUTH_API_BASE_ENV or AUTH_API_BASE_FIXED,
        "school_name": default_school,
        "ms_client_id": (preset.get("client_id") or os.getenv("MS_CLIENT_ID") or "").strip(),
        "ms_tenant_id": (preset.get("tenant_id") or os.getenv("MS_TENANT_ID") or "common").strip() or "common",
        "ms_force_device_login": False,
    }


def _load_settings() -> dict[str, Any]:
    _ensure_app_dirs()
    base = _default_settings()
    if not SETTINGS_PATH.exists():
        return base
    try:
        obj = json.loads(SETTINGS_PATH.read_text(encoding="utf-8"))
    except Exception:
        return base
    if not isinstance(obj, dict):
        return base
    out = dict(base)
    out.update(obj)
    try:
        out["retention_days"] = max(1, min(3650, int(out.get("retention_days", 30))))
    except Exception:
        out["retention_days"] = 30
    out["telemetry_enabled"] = bool(out.get("telemetry_enabled", False))
    out["auth_api_base"] = str(AUTH_API_BASE_ENV or AUTH_API_BASE_FIXED).strip().rstrip("/")
    out["school_name"] = str(out.get("school_name", next(iter(SCHOOL_PRESETS.keys()), "Favrskov Gymnasium")) or "").strip()
    out["ms_client_id"] = str(out.get("ms_client_id", "") or "").strip()
    out["ms_tenant_id"] = str(out.get("ms_tenant_id", "common") or "common").strip() or "common"
    out["ms_force_device_login"] = bool(out.get("ms_force_device_login", False))
    return out


def _save_settings(settings: dict[str, Any]) -> None:
    _ensure_app_dirs()
    obj = _default_settings()
    obj.update(settings or {})
    try:
        obj["retention_days"] = max(1, min(3650, int(obj.get("retention_days", 30))))
    except Exception:
        obj["retention_days"] = 30
    obj["telemetry_enabled"] = bool(obj.get("telemetry_enabled", False))
    obj["auth_api_base"] = str(AUTH_API_BASE_ENV or AUTH_API_BASE_FIXED).strip().rstrip("/")
    obj["school_name"] = str(obj.get("school_name", next(iter(SCHOOL_PRESETS.keys()), "Favrskov Gymnasium")) or "").strip()
    obj["ms_client_id"] = str(obj.get("ms_client_id", "") or "").strip()
    obj["ms_tenant_id"] = str(obj.get("ms_tenant_id", "common") or "common").strip() or "common"
    obj["ms_force_device_login"] = bool(obj.get("ms_force_device_login", False))
    SETTINGS_PATH.write_text(json.dumps(obj, ensure_ascii=False, indent=2), encoding="utf-8")


def _auth_api_enabled() -> bool:
    return bool(AUTH_API_BASE_RUNTIME)


def _set_auth_api_base(url: str) -> None:
    global AUTH_API_BASE_RUNTIME
    AUTH_API_BASE_RUNTIME = str(url or "").strip().rstrip("/")


def _auth_api_base() -> str:
    return AUTH_API_BASE_RUNTIME


def _auth_headers(token: str) -> dict[str, str]:
    t = (token or "").strip()
    if not t:
        return {}
    return {"Authorization": f"Bearer {t}"}


def _auth_api_request(
    method: str,
    path: str,
    *,
    token: str = "",
    json_body: Optional[dict[str, Any]] = None,
    timeout: int = 25,
) -> tuple[bool, int, Any, str]:
    base = _auth_api_base()
    if not base:
        return False, 0, None, "Auth API er ikke konfigureret."
    url = f"{base}{path}"
    try:
        r = requests.request(
            method=method.upper(),
            url=url,
            headers=_auth_headers(token),
            json=json_body,
            timeout=timeout,
        )
    except Exception as e:
        return False, 0, None, f"Kunne ikke kontakte auth server: {e}"

    try:
        data = r.json()
    except Exception:
        data = None

    if 200 <= r.status_code < 300:
        return True, r.status_code, data, ""

    err = ""
    if isinstance(data, dict):
        err = str(data.get("detail") or data.get("error") or "")
    if not err:
        err = (r.text or "").strip()[:500]
    if not err:
        err = f"Server-fejl ({r.status_code})"
    return False, r.status_code, data, err


def _auth_api_health(base_url: str) -> tuple[bool, str]:
    base = str(base_url or "").strip().rstrip("/")
    if not base:
        return False, "Ingen server-url angivet."
    try:
        r = requests.get(f"{base}/health", timeout=8)
    except Exception as e:
        return False, f"Kunne ikke kontakte server: {e}"
    if not (200 <= r.status_code < 300):
        return False, f"Health endpoint fejlede (HTTP {r.status_code})."
    try:
        data = r.json()
    except Exception:
        data = {}
    if isinstance(data, dict) and data.get("ok") is True:
        return True, "Server svarer."
    return True, "Server svarer (uventet health-format)."


def _auth_server_unreachable(err: str) -> bool:
    return "Kunne ikke kontakte auth server" in str(err or "")


def _local_app_version() -> str:
    if APP_VERSION_ENV:
        return APP_VERSION_ENV
    try:
        raw = APP_VERSION_FILE.read_text(encoding="utf-8").strip()
    except Exception:
        raw = ""
    return raw or "ukendt"


def _version_key(version: str) -> Optional[tuple[int, int, int]]:
    parts = [int(x) for x in re.findall(r"\d+", str(version or ""))]
    if not parts:
        return None
    while len(parts) < 3:
        parts.append(0)
    return tuple(parts[:3])


def _version_cmp(left: str, right: str) -> Optional[int]:
    l_key = _version_key(left)
    r_key = _version_key(right)
    if l_key is None or r_key is None:
        return None
    if l_key < r_key:
        return -1
    if l_key > r_key:
        return 1
    return 0


def _fetch_update_status(*, force_refresh: bool = False) -> dict[str, Any]:
    cached = st.session_state.get("app_update_status")
    now_ts = time.time()
    if (
        not force_refresh
        and isinstance(cached, dict)
        and (now_ts - float(cached.get("checked_at", 0.0) or 0.0)) < float(UPDATE_CHECK_TTL_SEC)
    ):
        return cached

    status: dict[str, Any] = {
        "checked_at": now_ts,
        "current_version": _local_app_version(),
        "latest_version": "",
        "minimum_supported_version": "",
        "download_url": "",
        "release_notes": "",
        "released_at": "",
        "update_available": False,
        "must_update": False,
        "server_supports_updates": False,
        "error": "",
    }

    ok, code, data, err = _auth_api_request("GET", "/v1/app/update", timeout=8)
    if not ok:
        if int(code or 0) == 404:
            status["error"] = "Opdateringsendpoint findes ikke endnu på serveren."
        elif err:
            status["error"] = str(err)
        st.session_state["app_update_status"] = status
        return status

    if not isinstance(data, dict):
        status["error"] = "Uventet svar fra opdateringsendpoint."
        st.session_state["app_update_status"] = status
        return status

    status["server_supports_updates"] = True
    status["latest_version"] = str(data.get("latest_version") or "").strip()
    status["minimum_supported_version"] = str(data.get("minimum_supported_version") or "").strip()
    status["download_url"] = str(data.get("download_url") or "").strip()
    status["release_notes"] = str(data.get("release_notes") or "").strip()
    status["released_at"] = str(data.get("released_at") or "").strip()

    latest = str(status["latest_version"] or "").strip()
    minimum = str(status["minimum_supported_version"] or "").strip()
    current = str(status["current_version"] or "").strip()

    cmp_latest = _version_cmp(current, latest) if latest else None
    cmp_min = _version_cmp(current, minimum) if minimum else None
    status["update_available"] = bool(cmp_latest == -1)
    status["must_update"] = bool(cmp_min == -1)

    st.session_state["app_update_status"] = status
    return status


def _render_update_notice(update_status: dict[str, Any], *, block_on_min_version: bool = False) -> None:
    current_version = str(update_status.get("current_version") or "ukendt")
    latest_version = str(update_status.get("latest_version") or "")
    min_version = str(update_status.get("minimum_supported_version") or "")
    download_url = str(update_status.get("download_url") or "").strip()
    release_notes = str(update_status.get("release_notes") or "").strip()
    released_at = str(update_status.get("released_at") or "").strip()
    error = str(update_status.get("error") or "").strip()
    update_available = bool(update_status.get("update_available"))
    must_update = bool(update_status.get("must_update"))

    st.caption(f"App-version: `{current_version}`")

    if must_update:
        msg = "Denne app-version er ikke længere understøttet."
        if min_version:
            msg += f" Minimum krævet version er `{min_version}`."
        st.error(msg)
        if latest_version:
            st.info(f"Nyeste version: `{latest_version}`")
        if release_notes:
            st.caption(f"Release notes: {release_notes}")
        if released_at:
            st.caption(f"Udgivet: {released_at}")
        if download_url:
            st.markdown(f"[Download ny version](<{download_url}>)")
        if block_on_min_version:
            st.stop()
        return

    if update_available:
        msg = "Der er en nyere version af appen."
        if latest_version:
            msg += f" Nyeste version: `{latest_version}`."
        st.warning(msg)
        if release_notes:
            st.caption(f"Release notes: {release_notes}")
        if released_at:
            st.caption(f"Udgivet: {released_at}")
        if download_url:
            st.markdown(f"[Opdater appen her](<{download_url}>)")
        return

    if error:
        st.caption(f"Opdateringstjek: {error}")


def _runtime_active_session_count() -> Optional[int]:
    try:
        from streamlit.runtime.runtime import Runtime

        if not Runtime.exists():
            return None
        runtime = Runtime.instance()
        session_mgr = getattr(runtime, "_session_mgr", None)
        if session_mgr is None or not hasattr(session_mgr, "num_active_sessions"):
            return None
        return int(session_mgr.num_active_sessions())
    except Exception:
        return None


def _exit_watchdog_loop(*, idle_grace_sec: int, startup_grace_sec: int, poll_sec: float) -> None:
    started_at = time.time()
    last_active_at = started_at
    while True:
        active_count = _runtime_active_session_count()
        now = time.time()
        if isinstance(active_count, int):
            if active_count > 0:
                last_active_at = now
            elif (now - started_at) >= float(startup_grace_sec) and (now - last_active_at) >= float(idle_grace_sec):
                os._exit(0)
        time.sleep(float(poll_sec))


@st.cache_resource(show_spinner=False)
def _ensure_exit_watchdog(enabled: bool, idle_grace_sec: int, startup_grace_sec: int, poll_sec: float) -> bool:
    if not enabled:
        return False
    thread = threading.Thread(
        target=_exit_watchdog_loop,
        kwargs={
            "idle_grace_sec": int(idle_grace_sec),
            "startup_grace_sec": int(startup_grace_sec),
            "poll_sec": float(poll_sec),
        },
        name="athenode-exit-watchdog",
        daemon=True,
    )
    thread.start()
    return True


def _remote_auth_login(username: str, password: str) -> tuple[bool, str, Optional[dict[str, Any]], str]:
    ok, _, data, err = _auth_api_request(
        "POST",
        "/v1/auth/login",
        json_body={"username": username, "password": password},
    )
    if not ok:
        return False, err or "Login fejlede.", None, ""
    if not isinstance(data, dict):
        return False, "Uventet svar fra server.", None, ""
    token = str(data.get("access_token") or "").strip()
    user = data.get("user")
    if not token or not isinstance(user, dict):
        return False, "Mangler token eller brugerdata i login-svar.", None, ""
    return True, "", user, token


def _remote_auth_register(username: str, password: str, email: str) -> tuple[bool, str, Optional[dict[str, Any]], str]:
    ok, _, data, err = _auth_api_request(
        "POST",
        "/v1/auth/register",
        json_body={"username": username, "password": password, "email": email},
    )
    if not ok:
        return False, err or "Oprettelse fejlede.", None, ""
    if not isinstance(data, dict):
        return False, "Uventet svar fra server.", None, ""
    token = str(data.get("access_token") or "").strip()
    user = data.get("user")
    if not token or not isinstance(user, dict):
        return False, "Mangler token eller brugerdata i registrerings-svar.", None, ""
    return True, "", user, token


def _remote_auth_me(token: str) -> tuple[bool, str, Optional[dict[str, Any]]]:
    ok, _, data, err = _auth_api_request("GET", "/v1/auth/me", token=token)
    if not ok:
        return False, err or "Kunne ikke hente konto-status.", None
    if not isinstance(data, dict):
        return False, "Uventet svar fra server.", None
    return True, "", data


def _remote_create_checkout_session(token: str, *, price_dkk: int) -> tuple[bool, str, str]:
    payload = {
        "price_dkk": int(max(1, price_dkk)),
        "success_url": DEFAULT_CHECKOUT_SUCCESS_URL,
        "cancel_url": DEFAULT_CHECKOUT_CANCEL_URL,
    }
    ok, _, data, err = _auth_api_request("POST", "/v1/billing/checkout-session", token=token, json_body=payload)
    if not ok:
        return False, err or "Kunne ikke oprette betalingssession.", ""
    if not isinstance(data, dict):
        return False, "Uventet svar fra server.", ""
    url = str(data.get("checkout_url") or "").strip()
    if not url:
        return False, "Mangler checkout URL i svar.", ""
    return True, "", url


def _remote_admin_list_users(token: str) -> tuple[bool, str, list[dict[str, Any]]]:
    ok, _, data, err = _auth_api_request("GET", "/v1/admin/users", token=token)
    if not ok:
        return False, err or "Kunne ikke hente brugere.", []
    if not isinstance(data, dict):
        return False, "Uventet svar fra server.", []
    users = data.get("users")
    if not isinstance(users, list):
        return False, "Uventet svarformat fra server.", []
    out: list[dict[str, Any]] = []
    for u in users:
        if not isinstance(u, dict):
            continue
        out.append(
            {
                "username": str(u.get("username") or ""),
                "email": str(u.get("email") or ""),
                "role": str(u.get("role") or "user"),
                "subscription_active": bool(u.get("subscription_active", False)),
                "created_at": str(u.get("created_at") or ""),
            }
        )
    out.sort(key=lambda r: r.get("username", ""))
    return True, "", out


def _remote_admin_create_user(
    token: str,
    *,
    username: str,
    password: str,
    role: str,
    subscription_active: bool,
) -> tuple[bool, str]:
    payload = {
        "username": _normalize_username(username),
        "password": password,
        "role": "admin" if str(role).strip().lower() == "admin" else "user",
        "subscription_active": bool(subscription_active),
    }
    ok, _, _, err = _auth_api_request("POST", "/v1/admin/users", token=token, json_body=payload)
    return (True, "Bruger oprettet.") if ok else (False, err or "Kunne ikke oprette bruger.")


def _remote_admin_update_user(
    token: str,
    *,
    username: str,
    role: str,
    subscription_active: bool,
) -> tuple[bool, str]:
    uname = _normalize_username(username)
    payload = {
        "role": "admin" if str(role).strip().lower() == "admin" else "user",
        "subscription_active": bool(subscription_active),
    }
    ok, _, _, err = _auth_api_request("PATCH", f"/v1/admin/users/{uname}", token=token, json_body=payload)
    return (True, "Bruger opdateret.") if ok else (False, err or "Kunne ikke opdatere bruger.")


def _remote_admin_reset_password(token: str, *, username: str, new_password: str) -> tuple[bool, str]:
    uname = _normalize_username(username)
    payload = {"new_password": new_password}
    ok, _, _, err = _auth_api_request(
        "POST",
        f"/v1/admin/users/{uname}/reset-password",
        token=token,
        json_body=payload,
    )
    return (True, "Adgangskode nulstillet.") if ok else (False, err or "Kunne ikke nulstille adgangskode.")


def _b64_encode(data: bytes) -> str:
    return base64.b64encode(data).decode("ascii")


def _b64_decode(data: str) -> bytes:
    return base64.b64decode((data or "").encode("ascii"))


def _password_hash(password: str, salt: bytes) -> bytes:
    return hashlib.pbkdf2_hmac("sha256", (password or "").encode("utf-8"), salt, 200_000)


def _hash_password(password: str) -> tuple[str, str]:
    salt = os.urandom(16)
    digest = _password_hash(password, salt)
    return _b64_encode(salt), _b64_encode(digest)


def _verify_password(password: str, salt_b64: str, hash_b64: str) -> bool:
    try:
        salt = _b64_decode(salt_b64)
        expected = _b64_decode(hash_b64)
    except Exception:
        return False
    got = _password_hash(password, salt)
    return hmac.compare_digest(got, expected)


def _load_users() -> dict[str, Any]:
    # Local auth is disabled in production-locked builds.
    return {"users": []}


def _save_users(obj: dict[str, Any]) -> None:
    _ = obj
    return None


def _find_user(users_obj: dict[str, Any], username: str) -> Optional[dict[str, Any]]:
    _ = users_obj
    _ = username
    return None


def _seed_admin_user() -> dict[str, str]:
    return {
        "username": "",
        "password": "",
        "created": "false",
        "from_env": "false",
    }


def _authenticate_account(username: str, password: str) -> tuple[bool, str, Optional[dict[str, Any]]]:
    _ = username
    _ = password
    return False, "Lokal login er deaktiveret.", None


def _normalize_username(username: str) -> str:
    return re.sub(r"\s+", "", (username or "").strip().lower())


def _valid_username(username: str) -> bool:
    return bool(re.fullmatch(r"[a-z0-9._-]{3,40}", _normalize_username(username)))


def _create_user_account(
    username: str,
    password: str,
    *,
    role: str = "user",
    subscription_active: bool = False,
) -> tuple[bool, str]:
    _ = username
    _ = password
    _ = role
    _ = subscription_active
    return False, "Lokal konto-oprettelse er deaktiveret."


def _update_user_access(username: str, *, role: str, subscription_active: bool) -> tuple[bool, str]:
    _ = username
    _ = role
    _ = subscription_active
    return False, "Lokal brugeradministration er deaktiveret."


def _reset_user_password(username: str, new_password: str) -> tuple[bool, str]:
    _ = username
    _ = new_password
    return False, "Lokal password-reset er deaktiveret."


def _admin_user_rows() -> list[dict[str, Any]]:
    return []


def _inject_login_css() -> None:
    st.markdown(
        """
<style>
[data-testid="stAppViewContainer"] {
  background:
    radial-gradient(1100px 520px at 10% 4%, rgba(59, 130, 246, 0.18) 0%, rgba(59, 130, 246, 0.0) 62%),
    radial-gradient(920px 540px at 88% -2%, rgba(16, 185, 129, 0.12) 0%, rgba(16, 185, 129, 0.0) 58%),
    var(--background-color);
}
[data-testid="stHeader"] {
  background: transparent;
}
.auth-kicker {
  display: inline-block;
  font-size: 0.76rem;
  letter-spacing: 0.08em;
  text-transform: uppercase;
  color: var(--primary-color);
  font-weight: 700;
  margin: 0 auto 0.35rem auto;
  text-align: center;
  width: 100%;
}
.auth-title {
  font-size: 1.7rem;
  font-weight: 800;
  color: var(--text-color);
  margin: 0 auto 0.35rem auto;
  text-align: center;
}
.auth-sub {
  color: var(--text-color);
  margin: 0 auto 1rem auto;
  text-align: center;
  max-width: 720px;
  opacity: 0.86;
}
.auth-sub-wrap {
  width: 100%;
  display: flex;
  justify-content: center;
}
.auth-sub-center {
  text-align: center !important;
}
.auth-note {
  margin-top: 0.85rem;
  font-size: 0.87rem;
  color: var(--text-color);
  opacity: 0.76;
}
[data-testid="stForm"] {
  border: 1px solid rgba(128, 128, 128, 0.34);
  border-radius: 18px;
  background: var(--secondary-background-color);
  box-shadow: 0 14px 36px rgba(15, 23, 42, 0.20);
  padding: 1.15rem 1.1rem 0.75rem 1.1rem;
}
[data-testid="stTextInput"] input {
  border-radius: 12px !important;
  border: 1px solid rgba(128, 128, 128, 0.34) !important;
  background: var(--background-color) !important;
  color: var(--text-color) !important;
}
[data-testid="stFormSubmitButton"] button,
[data-testid="stButton"] button {
  border-radius: 12px !important;
}
</style>
        """,
        unsafe_allow_html=True,
    )

def _school_names() -> list[str]:
    names = list(SCHOOL_PRESETS.keys())
    return names or ["Favrskov Gymnasium"]


def _school_credentials(school_name: str) -> tuple[str, str]:
    preset = SCHOOL_PRESETS.get(school_name or "", {})
    client_id = str(preset.get("client_id") or "").strip()
    tenant_id = str(preset.get("tenant_id") or "common").strip() or "common"
    return client_id, tenant_id


def _ms_token_cache_path() -> Path:
    return AUTH_DIR / "msal_onenote_token.bin"


def _clear_ms_token_cache() -> bool:
    p = _ms_token_cache_path()
    if p.exists():
        p.unlink()
        return True
    return False


def _is_subpath(path: Path, base: Path) -> bool:
    try:
        path.resolve().relative_to(base.resolve())
        return True
    except Exception:
        return False


def _safe_remove_tree(path: Path) -> None:
    rp = path.resolve()
    base = APP_DATA_DIR.resolve()
    if rp == base or _is_subpath(rp, base):
        if rp.exists():
            shutil.rmtree(rp)


def _delete_all_local_data() -> None:
    if APP_DATA_DIR.exists():
        _safe_remove_tree(APP_DATA_DIR)
    _ensure_app_dirs()
    _save_settings(_default_settings())


def _optional_runtime_status() -> dict[str, bool]:
    status: dict[str, bool] = {}
    for mod in ["pytesseract", "pypdf", "PyPDF2", "lxml", "PIL"]:
        status[mod] = importlib.util.find_spec(mod) is not None
    status["tesseract_bin"] = bool(shutil.which("tesseract"))
    return status


def _install_optional_python_packages() -> tuple[bool, str]:
    req = PROJECT_DIR / "requirements-optional.txt"
    if not req.exists():
        return False, f"Mangler requirements-fil: {req}"
    if getattr(sys, "frozen", False):
        return (
            False,
            "Installering fra den pakkede app er ikke understøttet.\n"
            "Brug i stedet source-mode eller byg en bundle med optional deps inkluderet.",
        )

    cmd = [sys.executable, "-m", "pip", "install", "-r", str(req)]
    p = subprocess.run(cmd, cwd=str(PROJECT_DIR), capture_output=True, text=True)
    out = ((p.stdout or "") + "\n" + (p.stderr or "")).strip()
    if p.returncode != 0:
        return False, out or "pip-installation fejlede uden output."
    return True, out or "Ekstra pakker installeret."


def _export_local_data_zip_bytes() -> bytes:
    _ensure_app_dirs()
    bio = io.BytesIO()
    with zipfile.ZipFile(bio, "w", compression=zipfile.ZIP_DEFLATED) as zf:
        if not APP_DATA_DIR.exists():
            zf.writestr("README.txt", "Ingen lokale data fundet.")
        else:
            for p in APP_DATA_DIR.rglob("*"):
                if not p.is_file():
                    continue
                if _is_subpath(p, AUTH_DIR):
                    continue
                rel = p.relative_to(APP_DATA_DIR)
                zf.write(p, arcname=str(rel))
    bio.seek(0)
    return bio.getvalue()


def _slugify_library_name(name: str) -> str:
    s = re.sub(r"[^a-zA-Z0-9_-]+", "-", (name or "").strip())
    s = re.sub(r"-{2,}", "-", s).strip("-").lower()
    return s or "default"


def _library_paths(library_name: str) -> tuple[Path, Path, Path]:
    slug = _slugify_library_name(library_name)
    lib_dir = LIBRARIES_DIR / slug
    state_path = lib_dir / "library_state.json"
    index_dir = lib_dir / "index"
    return lib_dir, state_path, index_dir


def _all_library_names() -> list[str]:
    if not LIBRARIES_DIR.exists():
        return []
    names: list[str] = []
    for d in LIBRARIES_DIR.iterdir():
        if not d.is_dir():
            continue
        if (d / "library_state.json").exists():
            names.append(d.name)
    return sorted(names)


def _load_library_state(library_name: str) -> dict[str, Any]:
    _, state_path, _ = _library_paths(library_name)
    if not state_path.exists():
        return {}
    try:
        obj = json.loads(state_path.read_text(encoding="utf-8"))
    except Exception:
        return {}
    if isinstance(obj, dict):
        return obj
    return {}


def _save_library_state(library_name: str, state: dict[str, Any]) -> None:
    lib_dir, state_path, _ = _library_paths(library_name)
    lib_dir.mkdir(parents=True, exist_ok=True)
    state_path.write_text(json.dumps(state, ensure_ascii=False, indent=2), encoding="utf-8")


def _normalize_export_roots(paths: list[str]) -> list[str]:
    if not isinstance(paths, (list, tuple)):
        return []
    out: list[str] = []
    seen = set()
    for p in paths or []:
        try:
            rp = str(Path(p).expanduser().resolve())
        except Exception:
            continue
        if rp in seen:
            continue
        if not chunk_note.is_export_root_dir(Path(rp)):
            continue
        seen.add(rp)
        out.append(rp)
    return out


def _parse_links(raw: str) -> list[str]:
    out: list[str] = []
    seen = set()
    for line in (raw or "").splitlines():
        v = line.strip()
        if not v:
            continue
        key = v.lower()
        if key in seen:
            continue
        seen.add(key)
        out.append(v)

    # OneNote copy/paste often includes both Doc.aspx and onenote: variants.
    # Prefer onenote: links when available to avoid redundant failed fetch attempts.
    onenote_links = [v for v in out if v.lower().startswith("onenote:")]
    if onenote_links:
        return onenote_links
    return out


def _root_signature(export_roots: list[str]) -> str:
    joined = "\n".join(sorted(export_roots))
    return hashlib.sha1(joined.encode("utf-8", errors="ignore")).hexdigest()


def _latest_export_inputs_mtime(export_root: Path) -> float:
    latest = 0.0
    manifest = export_root / "manifest.json"
    if manifest.exists():
        try:
            latest = max(latest, manifest.stat().st_mtime)
        except Exception:
            pass

    pages_dir = export_root / "pages"
    if pages_dir.exists():
        for pat in ("page.txt", "meta.json"):
            for p in pages_dir.rglob(pat):
                try:
                    latest = max(latest, p.stat().st_mtime)
                except Exception:
                    continue
    return latest


def _latest_sources_mtime(export_roots: list[str]) -> float:
    latest = 0.0
    for root in export_roots:
        try:
            latest = max(latest, _latest_export_inputs_mtime(Path(root)))
        except Exception:
            continue
    return latest


def _prune_library_export_roots(library_name: str, retention_days: int) -> tuple[list[str], int]:
    state = _load_library_state(library_name)
    raw_paths = state.get("export_roots", [])
    if not isinstance(raw_paths, list):
        raw_paths = []

    normalized = _normalize_export_roots(raw_paths)
    removed = max(0, len(raw_paths) - len(normalized))
    cutoff = time.time() - (max(1, retention_days) * 86400)

    kept: list[str] = []
    for rp in normalized:
        p = Path(rp)
        mtime = _latest_export_inputs_mtime(p)
        if mtime and mtime < cutoff:
            try:
                _safe_remove_tree(p)
                removed += 1
            except Exception:
                kept.append(rp)
        else:
            kept.append(rp)

    if removed > 0 or kept != normalized:
        state["export_roots"] = kept
        state["updated_at"] = datetime.now().isoformat()
        _save_library_state(library_name, state)

    return kept, removed


def _apply_retention_all_libraries(retention_days: int) -> tuple[int, int]:
    total_removed = 0
    touched = 0
    for lib in _all_library_names():
        _, removed = _prune_library_export_roots(lib, retention_days)
        total_removed += removed
        if removed > 0:
            touched += 1
    return total_removed, touched


def _build_chunks_from_export_roots(export_roots: list[str], chunks_path: Path) -> tuple[int, int]:
    enc = chunk_note.tiktoken.get_encoding(chunk_note.DEFAULT_ENCODING)
    all_chunks: list[chunk_note.Chunk] = []
    doc_count = 0

    for root in export_roots:
        root_p = Path(root)
        for doc in chunk_note.iter_documents(root_p):
            doc_count += 1
            all_chunks.extend(
                chunk_note.chunk_document(
                    doc,
                    enc=enc,
                    target_tokens=chunk_note.DEFAULT_TARGET_TOKENS,
                    overlap_tokens=chunk_note.DEFAULT_OVERLAP_TOKENS,
                    min_chunk_tokens=chunk_note.DEFAULT_MIN_CHUNK_TOKENS,
                )
            )

    if not all_chunks:
        raise RuntimeError("Ingen chunks fundet i valgte export roots. Tjek at siderne er eksporteret korrekt.")

    chunk_note.write_jsonl(all_chunks, chunks_path)
    return doc_count, len(all_chunks)


def _index_is_fresh(
    index_dir: Path,
    chunks_path: Path,
    *,
    embed_model: str,
    provider: str,
    host: str,
    api_base: str,
) -> bool:
    index_path = index_dir / "chunks_hnsw.bin"
    meta_path = index_dir / "chunks_meta.jsonl"
    cfg_path = index_dir / "index_config.json"
    if not (index_path.exists() and meta_path.exists() and cfg_path.exists() and chunks_path.exists()):
        return False

    try:
        idx_oldest = min(index_path.stat().st_mtime, meta_path.stat().st_mtime, cfg_path.stat().st_mtime)
        if idx_oldest < chunks_path.stat().st_mtime:
            return False
        cfg = json.loads(cfg_path.read_text(encoding="utf-8"))
        cfg_model = str(cfg.get("embed_model") or "")
        cfg_provider = str(cfg.get("provider") or "ollama").strip().lower()
        cfg_host = str(cfg.get("host") or "").rstrip("/")
        cfg_base = str(cfg.get("api_base") or "").rstrip("/")
        req_provider = str(provider or "ollama").strip().lower()
        req_host = str(host or "").rstrip("/")
        req_base = str(api_base or "").rstrip("/")
        endpoint_ok = (cfg_host == req_host) if req_provider == "ollama" else (cfg_base == req_base)
        return cfg_model == embed_model and cfg_provider == req_provider and endpoint_ok
    except Exception:
        return False


def _resolve_runtime_api_key(provider: str, api_key_input: str) -> Optional[str]:
    p = (provider or "ollama").strip().lower()
    if p != "openai":
        return None
    key = (api_key_input or "").strip()
    if key:
        return key
    key_env = (os.getenv("OPENAI_API_KEY") or "").strip()
    return key_env or None


def _parse_export_root(output: str) -> Optional[Path]:
    """Find EXPORT_ROOT=... in combined stdout+stderr."""
    m = re.search(r"EXPORT_ROOT=(.+)", output)
    if not m:
        return None
    p = m.group(1).strip().strip('"').strip("'")
    if not p:
        return None
    return Path(p).expanduser().resolve()


def run_fetch(
    section_url: str,
    *,
    enable_captions: bool,
    export_whole_section: bool,
    exact_page_only: bool,
    provider: str,
    openai_api_key: Optional[str],
    openai_api_base: str,
    ms_client_id: str,
    ms_tenant_id: str,
    force_device_login: bool,
) -> Path:
    """Run fetch script as a subprocess and return export root path."""
    _ensure_app_dirs()
    tenant = (ms_tenant_id or "common").strip() or "common"
    env = os.environ.copy()
    env["EXPORT_WHOLE_SECTION"] = "true" if export_whole_section else "false"
    env["EXACT_PAGE_ONLY"] = "true" if exact_page_only else "false"
    env["ENABLE_IMAGE_CAPTIONS"] = "true" if enable_captions else "false"
    env["OUTPUT_DIR"] = str(EXPORTS_DIR)
    env["MS_TOKEN_CACHE"] = str(_ms_token_cache_path())
    env["MS_CLIENT_ID"] = (ms_client_id or "").strip()
    env["MS_TENANT_ID"] = tenant
    env["MS_FORCE_DEVICE_LOGIN"] = "true" if force_device_login else "false"
    env["MS_AUTH_ONLY"] = "false"
    if (provider or "").strip().lower() == "openai" and (openai_api_key or "").strip():
        env["OCR_PROVIDER"] = "openai"
        env["OPENAI_API_KEY"] = (openai_api_key or "").strip()
        env["OPENAI_API_BASE"] = (openai_api_base or "https://api.openai.com").strip()
    else:
        env["OCR_PROVIDER"] = "auto"

    if getattr(sys, "frozen", False):
        # In PyInstaller app builds, sys.executable points to the app launcher.
        # Running subprocess with that executable recursively starts Streamlit again.
        env_keys = [
            "EXPORT_WHOLE_SECTION",
            "EXACT_PAGE_ONLY",
            "ENABLE_IMAGE_CAPTIONS",
            "OUTPUT_DIR",
            "MS_TOKEN_CACHE",
            "MS_CLIENT_ID",
            "MS_TENANT_ID",
            "MS_FORCE_DEVICE_LOGIN",
            "MS_AUTH_ONLY",
            "OCR_PROVIDER",
            "OPENAI_API_KEY",
            "OPENAI_API_BASE",
        ]
        old_env: dict[str, Optional[str]] = {k: os.environ.get(k) for k in env_keys}
        old_argv = list(sys.argv)
        stdout_buf = io.StringIO()
        stderr_buf = io.StringIO()
        fetch_error: Optional[BaseException] = None
        exit_code = 0

        try:
            for k in env_keys:
                os.environ[k] = env[k]
            # fetch script uses sys.argv[1] as TARGET_LINK override; provide explicit value.
            sys.argv = [str(FETCH_SCRIPT), section_url]

            with contextlib.redirect_stdout(stdout_buf), contextlib.redirect_stderr(stderr_buf):
                spec = importlib.util.spec_from_file_location("athenode_fetch_runtime", str(FETCH_SCRIPT))
                if spec is None or spec.loader is None:
                    raise RuntimeError(f"Kunne ikke indlæse fetch-script: {FETCH_SCRIPT}")
                fetch_mod = importlib.util.module_from_spec(spec)
                try:
                    spec.loader.exec_module(fetch_mod)
                    fetch_mod.main()
                except SystemExit as e:
                    code = e.code
                    if code is None:
                        exit_code = 0
                    elif isinstance(code, int):
                        exit_code = int(code)
                    else:
                        exit_code = 1
                except BaseException as e:
                    fetch_error = e
        finally:
            sys.argv = old_argv
            for k, v in old_env.items():
                if v is None:
                    os.environ.pop(k, None)
                else:
                    os.environ[k] = v

        combined = (stdout_buf.getvalue() or "") + "\n" + (stderr_buf.getvalue() or "")
        if fetch_error is not None:
            raise RuntimeError(combined.strip() or f"Fetch fejlede: {fetch_error}") from fetch_error
        if exit_code != 0:
            raise RuntimeError(combined.strip() or "Fetch fejlede uden output.")
    else:
        cmd = [sys.executable, str(FETCH_SCRIPT), section_url]
        p = subprocess.run(
            cmd,
            cwd=str(PROJECT_DIR),
            env=env,
            capture_output=True,
            text=True,
        )
        combined = (p.stdout or "") + "\n" + (p.stderr or "")
        if p.returncode != 0:
            raise RuntimeError(combined.strip() or "Fetch fejlede uden output.")

    export_root = _parse_export_root(combined)
    if not export_root:
        m2 = re.search(r"Eksporterer hele sektionen til:\s*(.+)", combined)
        if m2:
            export_root = Path(m2.group(1).strip()).expanduser().resolve()

    if not export_root:
        raise RuntimeError("Fetch kørte, men jeg kunne ikke finde EXPORT_ROOT i output.\n\n" + combined)

    if not export_root.exists():
        raise RuntimeError(f"EXPORT_ROOT peger på en mappe der ikke findes: {export_root}")

    return export_root


def run_ms_login(
    *,
    ms_client_id: str,
    ms_tenant_id: str,
    on_update: Optional[Callable[[str], None]] = None,
) -> None:
    """Force Microsoft device login via device-code flow."""
    _ensure_app_dirs()
    client_id = (ms_client_id or "").strip()
    tenant_id = (ms_tenant_id or "common").strip() or "common"
    if not client_id:
        raise RuntimeError("Microsoft client_id mangler.")

    cache_path = _ms_token_cache_path()
    cache_path.parent.mkdir(parents=True, exist_ok=True)

    try:
        import msal
    except Exception as e:
        raise RuntimeError(f"MSAL mangler i appen: {e}") from e

    if on_update:
        on_update("Starter Microsoft device-login ...")

    cache = msal.SerializableTokenCache()
    try:
        if cache_path.exists():
            cache.deserialize(cache_path.read_text(encoding="utf-8"))
    except Exception:
        cache = msal.SerializableTokenCache()

    authority = f"https://login.microsoftonline.com/{tenant_id}"
    app = msal.PublicClientApplication(client_id=client_id, authority=authority, token_cache=cache)

    flow = app.initiate_device_flow(scopes=["Notes.Read"])
    if "user_code" not in flow:
        raise RuntimeError("Kunne ikke starte device-code flow. Tjek Azure app/scopes.")

    login_url = str(flow.get("verification_uri_complete") or flow.get("verification_uri") or "").strip()
    login_code = str(flow.get("user_code") or "").strip().upper()
    if on_update:
        hint_parts = ["Log ind med din Microsoft-konto i en ny fane."]
        if login_url:
            hint_parts.append(f"[Åbn Microsoft login]({login_url})")
        if login_code:
            hint_parts.append(f"Engangskode: `{login_code}`")
        hint_parts.append("Siden fortsætter automatisk, når login er gennemført.")
        on_update("\n\n".join(hint_parts))

    result = app.acquire_token_by_device_flow(flow)
    if not isinstance(result, dict) or "access_token" not in result:
        detail = ""
        if isinstance(result, dict):
            detail = str(result.get("error_description") or result.get("error") or "").strip()
        raise RuntimeError(detail or "Microsoft login mislykkedes.")

    if cache.has_state_changed:
        cache_path.write_text(cache.serialize(), encoding="utf-8")

    if on_update:
        on_update("Microsoft-login fuldført.")


def prepare_index(
    export_roots: list[str],
    *,
    embed_model: str,
    provider: str,
    host: str,
    api_base: str,
    api_key: Optional[str],
    library_name: str,
) -> tuple[Path, object, object, dict]:
    roots = _normalize_export_roots(export_roots)
    if not roots:
        raise RuntimeError("Ingen gyldige export roots fundet for biblioteket.")

    lib_dir, _, index_dir = _library_paths(library_name)
    lib_dir.mkdir(parents=True, exist_ok=True)
    index_dir.mkdir(parents=True, exist_ok=True)
    chunks_path = (index_dir / "chunks.jsonl").resolve()

    state = _load_library_state(library_name)
    root_sig = _root_signature(roots)
    latest_src_mtime = _latest_sources_mtime(roots)

    need_chunks = True
    if chunks_path.exists() and chunks_path.stat().st_size > 0:
        built_sig = str(state.get("built_root_signature") or "")
        if built_sig == root_sig and chunks_path.stat().st_mtime >= latest_src_mtime:
            need_chunks = False

    if need_chunks:
        _build_chunks_from_export_roots(roots, chunks_path)

    if need_chunks or not _index_is_fresh(
        index_dir,
        chunks_path,
        embed_model=embed_model,
        provider=provider,
        host=host,
        api_base=api_base,
    ):
        build_index.build_index_from_chunks(
            chunks_path=chunks_path,
            out_dir=index_dir,
            embed_model=embed_model,
            provider=provider,
            host=host,
            api_base=api_base,
            api_key=api_key,
        )

    state["library_name"] = library_name
    state["export_roots"] = roots
    state["built_root_signature"] = root_sig
    state["last_build_at"] = datetime.now().isoformat()
    _save_library_state(library_name, state)

    index, meta, cfg = ask.load_index(index_dir)
    return index_dir, index, meta, cfg


def answer_question(
    *,
    index,
    meta,
    cfg: dict,
    question: str,
    chat_model: str,
    top_k: int,
    api_key: Optional[str] = None,
) -> tuple[str, list]:
    embed_model = cfg.get("embed_model") or "nomic-embed-text"
    provider = str(cfg.get("provider") or "ollama").strip().lower()
    host = cfg.get("host") or "http://localhost:11434"
    api_base = cfg.get("api_base") or ""

    if provider == "openai" and not (api_key or "").strip():
        raise RuntimeError("OpenAI er valgt, men API key mangler. Indtast din egen nøgle i appen.")

    hits = ask.retrieve(
        index,
        meta,
        question,
        embed_model=embed_model,
        host=host,
        top_k=top_k,
        provider=provider,
        api_base=api_base,
        api_key=api_key,
    )
    context = ask.build_context(hits)

    full_prompt = (
        "Spørgsmål:\n"
        f"{question}\n\n"
        "KONTEKST (uddrag fra dine noter — du må kun bruge dette):\n"
        f"{context}\n\n"
        "OPGAVE:\n"
        "- Svar på dansk og brug KUN konteksten.\n"
        "- Når du nævner en teoretiker/begreb/påstand, sæt kildehenvisning som [KILDE 1] osv.\n"
        "- Hvis noget ikke står i konteksten, så sig tydeligt at du ikke kan finde det i noterne.\n"
    )

    answer = ask.chat_completion(
        full_prompt,
        model=chat_model,
        provider=provider,
        host=host,
        api_base=api_base,
        api_key=api_key,
    ).strip()

    if not re.search(r"\bKILDE\b|\bkilde\b", answer):
        retry = (
            full_prompt
            + "\n\nDU GLEMTE KILDEHENVISNINGER: Skriv svaret igen og tilføj [KILDE 1], [KILDE 2] osv. ved alle konkrete påstande."
        )
        answer2 = ask.chat_completion(
            retry,
            model=chat_model,
            provider=provider,
            host=host,
            api_base=api_base,
            api_key=api_key,
        ).strip()
        if re.search(r"\bKILDE\b|\bkilde\b", answer2) and answer2:
            answer = answer2
        elif answer:
            answer = (
                "(Uden kildehenvisninger — kan være usikkert)\n"
                + answer
                + "\n\nJeg kan ikke garantere at dette står i de hentede kilder. Prøv at omformulere spørgsmålet eller øg top_k."
            )
        else:
            answer = "Jeg kan ikke finde det i dine noter (i de hentede kilder)."

    answer = ask.render_answer_with_citation_links(answer, hits)
    return answer, hits


def _init_state() -> None:
    st.session_state.setdefault("library_name", "default")
    st.session_state.setdefault("export_root", None)
    st.session_state.setdefault("export_roots", [])
    st.session_state.setdefault("index_dir", None)
    st.session_state.setdefault("index", None)
    st.session_state.setdefault("meta", None)
    st.session_state.setdefault("cfg", None)
    st.session_state.setdefault("chat_history", [])
    st.session_state.setdefault("data_export_bytes", None)
    st.session_state.setdefault("data_export_name", "")
    st.session_state.setdefault("provider", "ollama")
    st.session_state.setdefault("openai_api_key", "")
    st.session_state.setdefault("account_ok", False)
    st.session_state.setdefault("account_name", "")
    st.session_state.setdefault("account_role", "")
    st.session_state.setdefault("account_paid", False)
    st.session_state.setdefault("account_token", "")
    st.session_state.setdefault("checkout_url", "")
    st.session_state.setdefault("auth_ok", False)
    st.session_state.setdefault("school_name", next(iter(SCHOOL_PRESETS.keys()), "Favrskov Gymnasium"))
    st.session_state.setdefault("app_update_status", None)


def _reset_loaded_index() -> None:
    st.session_state.index_dir = None
    st.session_state.index = None
    st.session_state.meta = None
    st.session_state.cfg = None


def _render_first_run_checklist(*, account_name: str, account_role: str, school_name: str, tenant_id: str) -> None:
    provider_now = str(st.session_state.get("provider") or "ollama").strip().lower()
    openai_key_input = str(st.session_state.get("openai_api_key") or "").strip()
    runtime_api_key = _resolve_runtime_api_key(provider_now, openai_key_input)
    export_roots = _normalize_export_roots(st.session_state.get("export_roots", []))
    has_index = bool(st.session_state.get("index"))

    model_ready = (provider_now != "openai") or bool(runtime_api_key)
    model_detail = "Ollama lokal model" if provider_now == "ollama" else "OpenAI BYOK"
    if provider_now == "openai" and not model_ready:
        model_detail = "OpenAI valgt, men API key mangler"

    steps = [
        ("Konto login", bool(st.session_state.get("account_ok")), f"{account_name} ({account_role})"),
        ("Skole + Microsoft login", bool(st.session_state.get("auth_ok")), f"{school_name} / tenant {tenant_id}"),
        ("Model setup", model_ready, model_detail),
        ("Kilder hentet", bool(export_roots), f"{len(export_roots)} kilde-mappe(r)"),
        ("Index bygget", has_index, "Klar til chat" if has_index else "Klik 'Byg / load index' i sidebar"),
    ]
    done = sum(1 for _, is_done, _ in steps if is_done)

    with st.expander(f"Forste gang? Hurtig checkliste ({done}/{len(steps)})", expanded=done < len(steps)):
        st.caption("Koer punkterne oppefra og ned.")
        for label, is_done, detail in steps:
            marker = "[x]" if is_done else "[ ]"
            st.write(f"{marker} **{label}**: {detail}")
        if done < len(steps):
            st.info("Naar alle punkter er markeret, kan appen svare stabilt paa dine noter.")


def _render_admin_management_panel(*, account_token: str, account_name: str) -> None:
    st.subheader("Adminpanel")
    st.caption("Konto- og abonnementsstyring.")

    ok_rows, msg_rows, admin_rows = _remote_admin_list_users(account_token)
    if not ok_rows:
        st.error(f"Kunne ikke hente brugere fra server: {msg_rows}")
        admin_rows = []

    usernames = [str(r.get("username") or "") for r in admin_rows if str(r.get("username") or "").strip()]

    with st.expander("Brugeroversigt", expanded=True):
        if admin_rows:
            st.dataframe(admin_rows, width="stretch", hide_index=True)
        else:
            st.info("Ingen brugere fundet.")

    with st.form("admin-create-user", clear_on_submit=True):
        new_username = st.text_input("Nyt brugernavn")
        new_password = st.text_input("Midlertidig adgangskode", type="password")
        new_role = st.selectbox("Rolle", options=["user", "admin"], index=0)
        new_paid = st.checkbox("Aktiv betaling", value=False)
        do_create_user = st.form_submit_button("Opret bruger", width="stretch")
    if do_create_user:
        ok, msg = _remote_admin_create_user(
            account_token,
            username=new_username,
            password=new_password,
            role=new_role,
            subscription_active=new_paid,
        )
        if ok:
            st.success(msg)
            st.rerun()
        else:
            st.error(msg)

    if usernames:
        selected_default = usernames[0]
        if account_name in usernames:
            selected_default = account_name
        with st.form("admin-edit-user", clear_on_submit=False):
            selected_user = st.selectbox(
                "Redigér bruger",
                options=usernames,
                index=usernames.index(selected_default),
            )
            selected_row = next((r for r in admin_rows if r.get("username") == selected_user), {})
            role_idx = 1 if str(selected_row.get("role") or "user").strip().lower() == "admin" else 0
            edit_role = st.selectbox("Ny rolle", options=["user", "admin"], index=role_idx)
            edit_paid = st.checkbox(
                "Abonnement aktivt",
                value=bool(selected_row.get("subscription_active", False)),
            )
            do_update_user = st.form_submit_button("Gem ændringer", width="stretch")
        if do_update_user:
            ok, msg = _remote_admin_update_user(
                account_token,
                username=selected_user,
                role=edit_role,
                subscription_active=edit_paid,
            )
            if ok:
                if selected_user == account_name:
                    st.session_state.account_role = edit_role
                    st.session_state.account_paid = bool(edit_paid)
                st.success(msg)
                st.rerun()
            else:
                st.error(msg)

        with st.form("admin-reset-password", clear_on_submit=True):
            reset_user = st.selectbox("Nulstil password for", options=usernames, key="reset_user_select")
            reset_password = st.text_input("Ny adgangskode", type="password")
            do_reset_password = st.form_submit_button("Nulstil password", width="stretch")
        if do_reset_password:
            ok, msg = _remote_admin_reset_password(
                account_token,
                username=reset_user,
                new_password=reset_password,
            )
            if ok:
                st.success(msg)
            else:
                st.error(msg)


def main() -> None:
    _ensure_app_dirs()
    st.set_page_config(page_title="ATHENODE", layout="wide")
    _init_state()
    _ensure_exit_watchdog(
        bool(EXIT_ON_NO_BROWSER),
        int(EXIT_IDLE_GRACE_SEC),
        int(EXIT_STARTUP_GRACE_SEC),
        float(EXIT_POLL_SEC),
    )

    settings = _load_settings()
    _set_auth_api_base(str(AUTH_API_BASE_ENV or AUTH_API_BASE_FIXED))
    remote_mode = _auth_api_enabled()
    if not remote_mode:
        st.error("Auth-server er ikke konfigureret. Appen er låst til central login.")
        st.stop()
    update_status = _fetch_update_status()

    if not bool(st.session_state.get("account_ok")):
        _inject_login_css()
        st.markdown('<div class="auth-kicker">ATHENODE ACCESS</div>', unsafe_allow_html=True)
        st.markdown('<h1 class="auth-title">Log ind på din konto</h1>', unsafe_allow_html=True)
        st.markdown(
            '<div class="auth-sub-wrap"><p class="auth-sub auth-sub-center">Konto-login kommer før skolevalg og Microsoft-login.</p></div>',
            unsafe_allow_html=True,
        )
        _, col_mid, _ = st.columns([1, 1.2, 1])
        do_account_login = False
        do_account_register = False
        username = ""
        password = ""
        reg_username = ""
        reg_email = ""
        reg_password = ""
        reg_password_confirm = ""
        with col_mid:
            login_tab, register_tab = st.tabs(["Log ind", "Opret konto"])
            with login_tab:
                with st.form("account-login-form", clear_on_submit=False):
                    username = st.text_input("Brugernavn", key="account_login_username")
                    password = st.text_input("Adgangskode", type="password", key="account_login_password")
                    do_account_login = st.form_submit_button("Log ind", width="stretch")
            with register_tab:
                with st.form("account-register-form", clear_on_submit=False):
                    reg_username = st.text_input("Nyt brugernavn", key="account_register_username")
                    reg_email = st.text_input("Email", key="account_register_email")
                    reg_password = st.text_input("Ny adgangskode", type="password", key="account_register_password")
                    reg_password_confirm = st.text_input(
                        "Gentag adgangskode",
                        type="password",
                        key="account_register_password_confirm",
                    )
                    do_account_register = st.form_submit_button("Opret konto", width="stretch")
            st.markdown(
                f'<p class="auth-note">Central login aktiv: <code>{_auth_api_base()}</code></p>',
                unsafe_allow_html=True,
            )
            ok_health, msg_health = _auth_api_health(_auth_api_base())
            if ok_health:
                st.caption(f"Serverstatus: {msg_health}")
            else:
                st.error(f"Auth-server er utilgængelig: {msg_health}")
                st.info("Hvis serveren er nede, kan ingen logge ind. Tjek Railway status/service-log og prøv igen.")
            _render_update_notice(update_status, block_on_min_version=True)

        if do_account_register:
            if not str(reg_email or "").strip():
                st.error("Email er påkrævet.")
            elif (reg_password or "") != (reg_password_confirm or ""):
                st.error("Adgangskoderne matcher ikke.")
            else:
                ok, msg, user, token = _remote_auth_register(reg_username, reg_password, reg_email)
                if not ok:
                    st.error(msg)
                else:
                    role = str((user or {}).get("role") or "user").strip().lower()
                    paid = bool((user or {}).get("subscription_active", False))
                    st.session_state.account_ok = True
                    st.session_state.account_name = str((user or {}).get("username") or reg_username).strip()
                    st.session_state.account_role = role
                    st.session_state.account_paid = paid
                    st.session_state.account_token = token
                    st.session_state.checkout_url = ""
                    st.session_state.auth_ok = False
                    st.success("Konto oprettet.")
                    st.rerun()

        if do_account_login:
            ok, msg, user, token = _remote_auth_login(username, password)
            if not ok:
                st.error(msg)
            else:
                role = str((user or {}).get("role") or "user").strip().lower()
                paid = bool((user or {}).get("subscription_active", False))
                st.session_state.account_ok = True
                st.session_state.account_name = str((user or {}).get("username") or username).strip()
                st.session_state.account_role = role
                st.session_state.account_paid = paid
                st.session_state.account_token = token
                st.session_state.checkout_url = ""
                # Force school + Microsoft auth after account switch/login.
                st.session_state.auth_ok = False
                st.success("Konto-login lykkedes.")
                st.rerun()
        st.stop()

    account_role = str(st.session_state.get("account_role") or "user").strip().lower()
    account_paid = bool(st.session_state.get("account_paid", False))
    account_name = str(st.session_state.get("account_name") or "").strip() or "ukendt"
    account_token = str(st.session_state.get("account_token") or "").strip()
    if account_token:
        ok_me, msg_me, me = _remote_auth_me(account_token)
        if ok_me and isinstance(me, dict):
            account_name = str(me.get("username") or account_name).strip() or account_name
            account_role = str(me.get("role") or account_role).strip().lower() or account_role
            account_paid = bool(me.get("subscription_active", account_paid))
            st.session_state.account_name = account_name
            st.session_state.account_role = account_role
            st.session_state.account_paid = account_paid
        elif not ok_me:
            if _auth_server_unreachable(msg_me):
                st.error(f"Auth-server er utilgængelig: {msg_me}")
                st.info("Når serveren er oppe igen, genindlæs appen.")
                st.stop()
            st.warning(f"Konto-session udløbet/ugyldig: {msg_me}")
            st.session_state.account_ok = False
            st.session_state.account_name = ""
            st.session_state.account_role = ""
            st.session_state.account_paid = False
            st.session_state.account_token = ""
            st.session_state.checkout_url = ""
            st.rerun()

    if bool(update_status.get("must_update")):
        st.title("ATHENODE — Opdatering påkrævet")
        _render_update_notice(update_status, block_on_min_version=True)
    has_paid_access = (account_role == "admin") or account_paid
    if not has_paid_access:
        _inject_login_css()
        st.markdown('<div class="auth-kicker">ABONNEMENT KRÆVES</div>', unsafe_allow_html=True)
        st.markdown('<h1 class="auth-title">Ingen aktiv adgang</h1>', unsafe_allow_html=True)
        st.markdown(
            "<p class='auth-sub' style='text-align:center;'>Din konto har ikke en aktiv betaling endnu. Kontakt administrator for at aktivere adgang.</p>",
            unsafe_allow_html=True,
        )
        _, col_mid, _ = st.columns([1, 1.2, 1])
        with col_mid:
            if account_token:
                price_dkk = st.number_input(
                    "Pris i DKK",
                    min_value=1,
                    max_value=5000,
                    value=int(DEFAULT_CHECKOUT_PRICE_DKK),
                    step=1,
                )
                if st.button("Generér betalingslink", width="stretch"):
                    ok, msg, url = _remote_create_checkout_session(account_token, price_dkk=int(price_dkk))
                    if ok:
                        st.session_state.checkout_url = url
                    else:
                        st.error(msg)
                checkout_url = str(st.session_state.get("checkout_url") or "").strip()
                if checkout_url:
                    st.markdown(f"[Åbn betaling](<{checkout_url}>)")
                    st.caption("Efter gennemført betaling: log ind igen for at opdatere adgang.")
            if st.button("Log ud konto", width="stretch"):
                st.session_state.account_ok = False
                st.session_state.account_name = ""
                st.session_state.account_role = ""
                st.session_state.account_paid = False
                st.session_state.account_token = ""
                st.session_state.checkout_url = ""
                st.session_state.auth_ok = False
                st.rerun()
        st.stop()

    if account_role == "admin":
        st.title("ATHENODE — Admin")
        panel_mode = st.radio(
            "Arbejdsområde",
            options=["Adminpanel", "Studiepanel (test med Microsoft)"],
            horizontal=True,
            index=0,
        )
        if panel_mode == "Adminpanel":
            st.caption(f"Central auth-server: {_auth_api_base()}")
            ok_health, msg_health = _auth_api_health(_auth_api_base())
            if ok_health:
                st.success(msg_health)
            else:
                st.error(msg_health)
            _render_admin_management_panel(
                account_token=account_token,
                account_name=account_name,
            )
            if st.button("Log ud konto", width="stretch"):
                st.session_state.account_ok = False
                st.session_state.account_name = ""
                st.session_state.account_role = ""
                st.session_state.account_paid = False
                st.session_state.account_token = ""
                st.session_state.checkout_url = ""
                st.session_state.auth_ok = False
                st.rerun()
            st.info("Vælg 'Studiepanel (test med Microsoft)' hvis du vil teste elevflow med Microsoft-login.")
            st.stop()

    school_names = _school_names()
    saved_school = str(settings.get("school_name") or school_names[0]).strip()
    if saved_school not in school_names:
        saved_school = school_names[0]

    if not bool(st.session_state.get("auth_ok")):
        st.title("ATHENODE — Login")
        st.write("Vælg skole og log ind med din Microsoft-konto for at bruge dine egne OneNote-filer lokalt.")
        school_idx = school_names.index(saved_school) if saved_school in school_names else 0
        selected_school = st.selectbox("Vælg skole", options=school_names, index=school_idx)
        client_id, tenant_id = _school_credentials(selected_school)
        if not client_id:
            st.error("Skolekonfiguration mangler client_id. Kontakt administrator.")
            st.stop()

        if st.button("Fortsæt og log ind med Microsoft", width="stretch"):
            auth_hint = st.empty()
            auth_hint.info("Login starter ...")
            with st.spinner("Venter på Microsoft-login ..."):
                try:
                    run_ms_login(
                        ms_client_id=client_id,
                        ms_tenant_id=tenant_id,
                        on_update=lambda msg: auth_hint.markdown(msg),
                    )
                except Exception as e:
                    st.error(f"Login fejlede: {e}")
                else:
                    _save_settings(
                        {
                            "retention_days": int(settings.get("retention_days", 30)),
                            "telemetry_enabled": bool(settings.get("telemetry_enabled", False)),
                            "auth_api_base": _auth_api_base(),
                            "school_name": selected_school,
                            "ms_client_id": client_id,
                            "ms_tenant_id": tenant_id,
                            "ms_force_device_login": False,
                        }
                    )
                    st.session_state.auth_ok = True
                    st.session_state.school_name = selected_school
                    st.success("Du er logget ind. Fortsætter til appen ...")
                    st.rerun()
        st.stop()

    active_school = str(st.session_state.get("school_name") or saved_school).strip()
    if active_school not in school_names:
        active_school = school_names[0]
    ms_client_id_active, ms_tenant_id_active = _school_credentials(active_school)
    if not ms_client_id_active:
        st.error("Skolekonfiguration mangler Microsoft client_id.")
        st.stop()

    st.title("ATHENODE — OneNote → RAG")
    _render_first_run_checklist(
        account_name=account_name,
        account_role=account_role,
        school_name=active_school,
        tenant_id=ms_tenant_id_active,
    )

    with st.sidebar:
        st.header("1) Kildebibliotek")
        library_name_input = st.text_input(
            "Bibliotek-navn",
            value=st.session_state.get("library_name") or "default",
            help="Brug forskellige navne pr. fag/hold for isolation.",
        ).strip() or "default"
        links_raw = st.text_area(
            "OneNote links (én pr. linje)",
            height=120,
            placeholder="Indsæt ét eller flere onenote:https://... links",
        ).strip()
        st.caption(f"Konto: {account_name} ({account_role})")
        do_logout_account = st.button("Log ud konto", width="stretch")
        st.caption(f"Skole: {active_school}")
        do_logout_ms = st.button("Skift skole / log ud Microsoft", width="stretch")
        if bool(update_status.get("update_available")):
            latest = str(update_status.get("latest_version") or "").strip()
            download_url = str(update_status.get("download_url") or "").strip()
            st.warning(
                "Ny app-version tilgængelig."
                + (f" ({latest})" if latest else "")
            )
            if download_url:
                st.markdown(f"[Opdater app](<{download_url}>)")

        export_whole_section = st.checkbox("Eksportér hele sektionen (incremental: henter kun nyt)", value=True)
        exact_page_only = st.checkbox("Hvis det er en side: hent kun den præcise side", value=True)
        enable_captions = st.checkbox("Lav billed-captions (Ollama vision)", value=False)

        st.divider()
        st.header("2) Modeller")
        provider = st.selectbox(
            "Provider",
            options=["ollama", "openai"],
            index=0 if st.session_state.get("provider") == "ollama" else 1,
            help="openai = BYOK (din egen API key). ollama = lokal model.",
        )
        st.session_state.provider = provider

        if provider == "openai":
            api_base = st.text_input("OpenAI base URL", value="https://api.openai.com").strip()
            openai_api_key_input = st.text_input(
                "OpenAI API key (BYOK)",
                value=st.session_state.get("openai_api_key") or "",
                type="password",
                help="Gemmes ikke på disk. Hvis tom, forsøger appen miljøvariablen OPENAI_API_KEY.",
            ).strip()
            st.session_state.openai_api_key = openai_api_key_input
            host = ""
        else:
            host = st.text_input("Ollama host", value="http://localhost:11434").strip()
            api_base = ""
            st.session_state.openai_api_key = ""

        default_embed_model = "text-embedding-3-small" if provider == "openai" else "nomic-embed-text"
        default_chat_model = "gpt-4o-mini" if provider == "openai" else "mistral"
        embed_model = st.text_input("Embedding model", value=default_embed_model).strip()
        chat_model = st.text_input("Chat model", value=default_chat_model).strip()
        top_k = st.slider("Top-k kilder", min_value=2, max_value=20, value=6, step=1)

        st.divider()
        st.header("3) Data & Privacy")
        opt_status = _optional_runtime_status()
        st.caption(
            "Ekstra pakker (OCR/PDF): "
            + ", ".join(
                [
                    f"{k}={'OK' if v else 'Mangler'}"
                    for k, v in [
                        ("pytesseract", opt_status.get("pytesseract", False)),
                        ("pypdf", opt_status.get("pypdf", False)),
                        ("PIL", opt_status.get("PIL", False)),
                        ("tesseract-bin", opt_status.get("tesseract_bin", False)),
                    ]
                ]
            )
        )
        do_install_extras = st.button("Installer ekstra pakker (OCR/PDF)", width="stretch")
        retention_days_input = int(
            st.number_input(
                "Retention (dage)",
                min_value=1,
                max_value=3650,
                value=int(settings.get("retention_days", 30)),
                step=1,
            )
        )
        telemetry_enabled_input = st.checkbox(
            "Tillad anonym telemetri",
            value=bool(settings.get("telemetry_enabled", False)),
            help="Default er slået fra.",
        )
        do_save_privacy = st.button("Gem privacy-indstillinger", width="stretch")
        do_prune = st.button("Ryd gamle lokale data nu", width="stretch")
        do_export_data = st.button("Forbered lokal data-eksport (.zip)", width="stretch")
        do_delete_data = st.button("Slet alle lokale data", width="stretch")

        st.divider()
        st.header("4) Handling")
        do_fetch = st.button("Hent / eksportér links", width="stretch")
        do_build = st.button("Byg / load index", width="stretch")
        do_clear = st.button("Nulstil session", width="stretch")

    if do_save_privacy or do_prune:
        _save_settings(
            {
                "retention_days": retention_days_input,
                "telemetry_enabled": telemetry_enabled_input,
                "auth_api_base": _auth_api_base(),
                "school_name": active_school,
                "ms_client_id": ms_client_id_active,
                "ms_tenant_id": ms_tenant_id_active,
                "ms_force_device_login": False,
            }
        )
        settings = _load_settings()
        if do_save_privacy:
            st.success("Privacy-indstillinger gemt.")

    if do_install_extras:
        ok, msg = _install_optional_python_packages()
        if ok:
            st.success("Ekstra Python-pakker installeret.")
            st.code(msg[:4000], language="")
            st.info("Hvis OCR stadig mangler, installer også Tesseract på maskinen.")
        else:
            st.error("Kunne ikke installere ekstra pakker automatisk.")
            st.code(msg[:4000], language="")
            st.info(
                "macOS: `python3 -m pip install -r requirements-optional.txt` og `brew install tesseract`.\n"
                "Windows: `py -m pip install -r requirements-optional.txt` og installer Tesseract OCR."
            )

    if do_logout_ms:
        try:
            cleared = _clear_ms_token_cache()
        except Exception as e:
            st.error(f"Kunne ikke slette Microsoft token-cache: {e}")
        else:
            st.session_state.auth_ok = False
            st.session_state.school_name = active_school
            if cleared:
                st.success("Microsoft token-cache slettet.")
            else:
                st.info("Ingen lokal Microsoft token-cache fundet.")
            st.rerun()

    if do_logout_account:
        st.session_state.account_ok = False
        st.session_state.account_name = ""
        st.session_state.account_role = ""
        st.session_state.account_paid = False
        st.session_state.account_token = ""
        st.session_state.checkout_url = ""
        st.session_state.auth_ok = False
        st.rerun()

    if do_prune:
        removed, touched = _apply_retention_all_libraries(retention_days_input)
        loaded = _load_library_state(library_name_input)
        roots = _normalize_export_roots(loaded.get("export_roots", []))
        st.session_state.export_roots = roots
        st.session_state.export_root = roots[0] if roots else None
        _reset_loaded_index()
        st.session_state.chat_history = []
        st.info(f"Retention-kørsel færdig: {removed} eksportmappe(r) fjernet i {touched} bibliotek(er).")

    if do_export_data:
        z = _export_local_data_zip_bytes()
        ts = datetime.now().strftime("%Y%m%d_%H%M%S")
        st.session_state.data_export_bytes = z
        st.session_state.data_export_name = f"athenode_local_data_{ts}.zip"
        st.success("Data-eksport er klar til download.")

    if st.session_state.get("data_export_bytes"):
        st.sidebar.download_button(
            "Download lokal data (.zip)",
            data=st.session_state.data_export_bytes,
            file_name=st.session_state.data_export_name or "athenode_local_data.zip",
            mime="application/zip",
            width="stretch",
        )

    if do_delete_data:
        try:
            _delete_all_local_data()
        except Exception as e:
            st.error(f"Kunne ikke slette lokale data: {e}")
        else:
            for k in [
                "export_root",
                "export_roots",
                "index_dir",
                "index",
                "meta",
                "cfg",
                "chat_history",
                "data_export_bytes",
                "data_export_name",
            ]:
                if k in st.session_state:
                    del st.session_state[k]
            _init_state()
            st.session_state.library_name = library_name_input
            st.success("Alle lokale data er slettet.")

    if st.session_state.get("library_name") != library_name_input:
        st.session_state.library_name = library_name_input
        loaded = _load_library_state(library_name_input)
        roots = _normalize_export_roots(loaded.get("export_roots", []))
        st.session_state.export_roots = roots
        st.session_state.export_root = roots[0] if roots else None
        _reset_loaded_index()
        st.session_state.chat_history = []

    if not st.session_state.get("export_roots"):
        loaded = _load_library_state(st.session_state.library_name)
        roots = _normalize_export_roots(loaded.get("export_roots", []))
        if roots:
            st.session_state.export_roots = roots
            st.session_state.export_root = roots[0]

    if do_clear:
        for k in [
            "export_root",
            "export_roots",
            "index_dir",
            "index",
            "meta",
            "cfg",
            "chat_history",
            "data_export_bytes",
            "data_export_name",
        ]:
            if k in st.session_state:
                del st.session_state[k]
        _init_state()
        st.session_state.library_name = library_name_input
        loaded = _load_library_state(library_name_input)
        roots = _normalize_export_roots(loaded.get("export_roots", []))
        st.session_state.export_roots = roots
        st.session_state.export_root = roots[0] if roots else None
        st.success("Session nulstillet.")

    col_a, col_b = st.columns([1, 1])

    with col_a:
        st.subheader("Status")
        export_roots = _normalize_export_roots(st.session_state.get("export_roots", []))
        index_dir = st.session_state.get("index_dir")
        lib_name = st.session_state.get("library_name") or "default"

        st.write("**Projektmappe:**", str(PROJECT_DIR))
        st.write("**Lokal data-mappe:**", str(APP_DATA_DIR))
        st.write("**Eksportmappe:**", str(EXPORTS_DIR))
        st.write("**Bibliotek:**", lib_name)
        st.write("**Konto:**", f"{account_name} ({account_role})")
        st.write("**App-version:**", str(update_status.get("current_version") or "ukendt"))
        st.write("**Auth mode:**", "Central server")
        st.write("**Skole:**", active_school)
        st.write("**Microsoft tenant:**", ms_tenant_id_active)
        st.write("**Kilder (export roots):**", len(export_roots))
        if export_roots:
            with st.expander("Vis kildemapper", expanded=False):
                for r in export_roots:
                    st.code(r, language="")
        st.write("**Index dir:**", str(index_dir) if index_dir else "(ingen endnu)")
        st.write("**Telemetri:**", "Aktiveret" if bool(settings.get("telemetry_enabled")) else "Deaktiveret")

        if do_fetch:
            links = _parse_links(links_raw)
            if not links:
                st.error("Du skal indsætte mindst ét OneNote link (én pr. linje).")
            elif not (ms_client_id_active or "").strip():
                st.error("Microsoft Client ID mangler for den valgte skole.")
            else:
                with st.spinner(f"Henter {len(links)} link(s) fra OneNote …"):
                    loaded = _load_library_state(lib_name)
                    merged_roots = _normalize_export_roots(loaded.get("export_roots", []))
                    fetched_roots: list[str] = []
                    failures: list[tuple[str, str]] = []
                    fetch_openai_key = _resolve_runtime_api_key(provider, st.session_state.get("openai_api_key", ""))

                    for link in links:
                        try:
                            root = run_fetch(
                                link,
                                enable_captions=enable_captions,
                                export_whole_section=export_whole_section,
                                exact_page_only=exact_page_only,
                                provider=provider,
                                openai_api_key=fetch_openai_key,
                                openai_api_base=api_base,
                                ms_client_id=ms_client_id_active,
                                ms_tenant_id=ms_tenant_id_active,
                                force_device_login=False,
                            )
                            fetched_roots.append(str(root))
                        except Exception as e:
                            failures.append((link, str(e)))

                    merged_roots = _normalize_export_roots(merged_roots + fetched_roots)
                    loaded["library_name"] = lib_name
                    loaded["export_roots"] = merged_roots
                    loaded["updated_at"] = datetime.now().isoformat()
                    _save_library_state(lib_name, loaded)

                    st.session_state.export_roots = merged_roots
                    st.session_state.export_root = merged_roots[0] if merged_roots else None

                    if fetched_roots:
                        _reset_loaded_index()
                        st.success(
                            f"Hentede/opdaterede {len(fetched_roots)} link(s). Biblioteket har nu {len(merged_roots)} kilde-map(per)."
                        )

                    if failures:
                        st.warning(f"{len(failures)} link(s) fejlede under hentning.")
                        for link, err in failures:
                            st.error(f"Fejl for link: {link}\n\n{err[:1200]}")

        if do_build:
            roots = _normalize_export_roots(st.session_state.get("export_roots", []))
            if not roots:
                loaded = _load_library_state(lib_name)
                roots = _normalize_export_roots(loaded.get("export_roots", []))
                st.session_state.export_roots = roots
                st.session_state.export_root = roots[0] if roots else None

            if not roots:
                st.error("Ingen kilder i biblioteket. Kør 'Hent / eksportér links' først.")
            else:
                runtime_api_key = _resolve_runtime_api_key(provider, st.session_state.get("openai_api_key", ""))
                if provider == "openai" and not runtime_api_key:
                    st.error("OpenAI er valgt, men API key mangler. Indtast din nøgle eller sæt OPENAI_API_KEY.")
                else:
                    with st.spinner("Bygger/loader chunks og index …"):
                        try:
                            index_dir, index, meta, cfg = prepare_index(
                                roots,
                                embed_model=embed_model,
                                provider=provider,
                                host=host,
                                api_base=api_base,
                                api_key=runtime_api_key,
                                library_name=lib_name,
                            )
                            st.session_state.index_dir = str(index_dir)
                            st.session_state.index = index
                            st.session_state.meta = meta
                            st.session_state.cfg = cfg
                            st.success(f"Index klar: {index_dir} ({len(roots)} kildemapper)")
                        except Exception as e:
                            st.error(str(e))

    with col_b:
        st.subheader("Chat")

        if not st.session_state.get("index"):
            st.info("Byg / load index først. Så kan du stille spørgsmål.")

        for msg in st.session_state.chat_history:
            role = msg.get("role")
            content = msg.get("content")
            with st.chat_message(role):
                st.markdown(content)

        question = st.chat_input("Skriv et spørgsmål til dine noter …")
        if question:
            st.session_state.chat_history.append({"role": "user", "content": question})
            with st.chat_message("user"):
                st.markdown(question)

            if not st.session_state.get("index"):
                with st.chat_message("assistant"):
                    st.markdown("Jeg kan ikke svare endnu — index mangler. Klik 'Byg / load index' i sidebaren.")
                st.session_state.chat_history.append(
                    {"role": "assistant", "content": "Jeg kan ikke svare endnu — index mangler."}
                )
            else:
                with st.chat_message("assistant"):
                    with st.spinner("Tænker med dine noter …"):
                        try:
                            runtime_provider = str((st.session_state.cfg or {}).get("provider") or provider).strip().lower()
                            runtime_api_key = _resolve_runtime_api_key(
                                runtime_provider,
                                st.session_state.get("openai_api_key", ""),
                            )
                            ans_text, hits = answer_question(
                                index=st.session_state.index,
                                meta=st.session_state.meta,
                                cfg=st.session_state.cfg,
                                question=question.strip(),
                                chat_model=chat_model,
                                top_k=int(top_k),
                                api_key=runtime_api_key,
                            )
                        except Exception as e:
                            ans_text = f"Fejl: {e}"
                            hits = []

                    st.markdown(ans_text)

                    with st.expander("Vis hentede kilder (KILDE 1, KILDE 2, …)", expanded=False):
                        if not hits:
                            st.write("(Ingen kilder hentet)")
                        else:
                            for i, h in enumerate(hits, start=1):
                                c = h.get("chunk", {})
                                dist = h.get("distance")
                                score = h.get("score")
                                mode = h.get("retrieval_mode")
                                title = c.get("title") or "(uden titel)"
                                hp = " > ".join(c.get("heading_path") or [])
                                src = c.get("source_path") or ""
                                parts = [f"**KILDE {i}**"]
                                if isinstance(score, (int, float)):
                                    parts.append(f"score={float(score):.3f}")
                                if isinstance(dist, (int, float)):
                                    parts.append(f"dist={float(dist):.4f}")
                                if mode:
                                    parts.append(str(mode))
                                parts.append(f"**{title}**")
                                line = " — ".join(parts) + (f" — {hp}" if hp else "")
                                st.markdown(line)
                                source_url = ask.best_source_url_for_chunk(c)
                                if source_url:
                                    st.markdown(f"[Åbn kilde i OneNote/SharePoint](<{source_url}>)")
                                if src:
                                    st.code(src, language="")
                                snippet = (c.get("text") or "").strip()
                                if snippet:
                                    st.text(snippet[:1200] + ("…" if len(snippet) > 1200 else ""))

                st.session_state.chat_history.append({"role": "assistant", "content": ans_text})


if __name__ == "__main__":
    main()
