"""Dateifreigaben: SMB-Einbindungen und sicheres Blaettern im Dateibaum.

Einbindungen entstehen ausschliesslich nach einer erfolgreichen Anmeldung gegen
den Verzeichnisdienst -- lokale Konten haben hier grundsaetzlich nichts zu
sehen. *Welche* Freigaben sichtbar sind, entscheidet allein die Gruppen-
mitgliedschaft im Verzeichnis; das Recht ``fileshare.view`` entscheidet, ob
ueberhaupt eingebunden wird.

Sicherheitsrelevante Aenderungen gegenueber dem Vorgaenger:

* **Das Passwort geht nie ueber die Umgebung.** Der Vorgaenger uebergab es per
  ``PASSWD``-Variable an ``mount.cifs`` -- sichtbar in ``/proc/<pid>/environ``
  fuer denselben Benutzer und fuer root. Hier schreibt der privilegierte Helfer
  es in eine Datei mit Modus 0600, die unmittelbar danach geloescht wird.
* **Pfadpruefung mit ``Path.resolve()``.** Jeder Zugriff wird gegen das
  Wurzelverzeichnis der Sitzung geprueft; Symlinks koennen nicht herausfuehren.
* **Einbindungen stehen in der Datenbank.** Der Aufraeumprozess liest sie dort,
  statt das Dateisystem zu durchsuchen -- und ein Neustart des Dienstes verliert
  nicht mehr die Information, was noch eingebunden ist.
"""

from __future__ import annotations

import logging
import mimetypes
import os
import re
import secrets
import shutil
import sqlite3
from dataclasses import dataclass
from datetime import datetime, timedelta, timezone
from pathlib import Path, PurePosixPath
from typing import Any, Iterable

from tesm_core.db import Database
from tesm_core.extension import core

logger = logging.getLogger(__name__)

_UNC_HINT = (
    "Der Freigabepfad muss die Form \\\\server\\freigabe haben. Schraegstriche "
    "gehen auch: //server/freigabe."
)

UNC_RE = re.compile(r"^\\\\[A-Za-z0-9._-]+\\[^\\/:*?\"<>|]+(\\[^\\/:*?\"<>|]+)*$")

#: Endungen, die inline angezeigt werden duerfen -- alles andere nur als Download.
#: ``.doc`` fehlt bewusst: alte Word-Dateien koennen Makros tragen und werden von
#: Browsern uneinheitlich behandelt.
PREVIEW_MIME: dict[str, str] = {
    ".pdf": "application/pdf",
    ".jpg": "image/jpeg",
    ".jpeg": "image/jpeg",
    ".png": "image/png",
    ".gif": "image/gif",
    ".webp": "image/webp",
    ".bmp": "image/bmp",
    ".txt": "text/plain; charset=utf-8",
    ".csv": "text/plain; charset=utf-8",
    ".log": "text/plain; charset=utf-8",
    ".md": "text/plain; charset=utf-8",
    ".json": "text/plain; charset=utf-8",
    ".xml": "text/plain; charset=utf-8",
}
#: SVG ist bewusst *nicht* in der Vorschau: es kann Skript enthalten und wuerde
#: im Ursprung der Anwendung ausgefuehrt. Download bleibt moeglich.


class FileshareError(Exception):
    """Fehler mit Klartextmeldung fuer die Oberflaeche."""


def _now() -> datetime:
    return datetime.now(timezone.utc)


def _iso(value: datetime) -> str:
    return value.isoformat(timespec="seconds")


def validate_unc(value: str) -> str:
    """Prueft und vereinheitlicht einen Freigabepfad.

    Beide Schreibweisen sind erlaubt und bedeuten dasselbe::

        \\\\dc2022\\daten
        //dc2022/daten

    Gespeichert wird immer die Backslash-Form; der Helfer dreht sie fuer
    ``mount`` wieder um. Ueberzaehlige Trenner und ein Trenner am Ende werden
    entfernt, statt den Pfad abzulehnen: genau so sieht ein Pfad aus, den
    jemand aus dem Explorer oder aus einem Browser kopiert hat, und eine
    Ablehnung dafuer waere Schikane ohne Gewinn.
    """
    text = (value or "").strip().replace("/", "\\")
    # Der doppelte Trenner am Anfang macht den Pfad zur Freigabe. Ein einfacher
    # waere ein absoluter Pfad auf diesem Rechner -- etwas voellig anderes.
    if not text.startswith("\\\\"):
        raise FileshareError(_UNC_HINT)

    teile = [teil for teil in text.split("\\") if teil]
    if len(teile) < 2:
        raise FileshareError(_UNC_HINT)

    text = "\\\\" + "\\".join(teile)
    if not UNC_RE.match(text):
        raise FileshareError(_UNC_HINT)
    return text


# --------------------------------------------------------------------------- #
# Zuordnungen
# --------------------------------------------------------------------------- #


def list_mappings(conn: sqlite3.Connection) -> list[dict[str, Any]]:
    return Database.all(
        conn, "SELECT * FROM fileshare_mappings ORDER BY label COLLATE NOCASE"
    )


def add_mapping(
    conn: sqlite3.Connection, *, ad_group_dn: str, ad_group_name: str, label: str, unc: str
) -> int:
    clean_unc = validate_unc(unc)
    clean_label = (label or "").strip()
    if not clean_label:
        raise FileshareError("Der Anzeigename darf nicht leer sein.")
    if not (ad_group_dn or "").strip():
        raise FileshareError("Es ist keine Verzeichnisgruppe ausgewaehlt.")
    cursor = conn.execute(
        "INSERT INTO fileshare_mappings (ad_group_dn, ad_group_name, label, unc, created_at) "
        "VALUES (?,?,?,?,?) ON CONFLICT(ad_group_dn, unc) DO NOTHING",
        (ad_group_dn.strip(), (ad_group_name or "").strip(), clean_label, clean_unc, _iso(_now())),
    )
    if not cursor.rowcount:
        raise FileshareError("Diese Zuordnung existiert bereits.")
    return int(cursor.lastrowid or 0)


def delete_mapping(conn: sqlite3.Connection, mapping_id: int) -> None:
    conn.execute("DELETE FROM fileshare_mappings WHERE id=?", (mapping_id,))


def shares_for_groups(
    conn: sqlite3.Connection, group_dns: Iterable[str]
) -> list[dict[str, Any]]:
    """Freigaben, auf die diese Verzeichnisgruppen Anspruch geben.

    Es gibt bewusst keinen zweiten Weg: eine Freigabe wird mit dem Konto des
    angemeldeten Verzeichnisbenutzers eingebunden. Ein lokales TESM-Konto hat
    kein Passwort, das ein Fileserver akzeptieren wuerde -- eine Zuordnung an
    eine lokale Gruppe waere deshalb eine Zuordnung, die nie zu einer
    Einbindung fuehrt.
    """
    lowered = {dn.strip().lower() for dn in group_dns if dn and dn.strip()}
    if not lowered:
        return []
    return [
        row
        for row in list_mappings(conn)
        if str(row["ad_group_dn"] or "").lower() in lowered
    ]


# --------------------------------------------------------------------------- #
# Einbindungen
# --------------------------------------------------------------------------- #


@dataclass(frozen=True, slots=True)
class Mount:
    id: int
    token: str
    label: str
    unc: str
    path: Path
    expires_at: str


def mount_root() -> Path:
    from tesm_core.extension import db

    return Path(str(core().settings.get(db(), "fileshare_mount_root") or "/mnt/tesm-shares"))


def active_mounts(conn: sqlite3.Connection, user_id: int) -> list[Mount]:
    rows = Database.all(
        conn,
        "SELECT * FROM fileshare_mounts WHERE user_id=? AND released_at IS NULL "
        "AND expires_at > ? ORDER BY label COLLATE NOCASE",
        (user_id, _iso(_now())),
    )
    return [
        Mount(
            id=int(row["id"]),
            token=str(row["token"]),
            label=str(row["label"]),
            unc=str(row["unc"]),
            path=Path(str(row["mount_path"])),
            expires_at=str(row["expires_at"]),
        )
        for row in rows
    ]


def has_mounts(user_id: int) -> bool:
    from tesm_core.extension import db

    return bool(active_mounts(db(), user_id))


def mount_for_login(
    conn: sqlite3.Connection,
    *,
    user_id: int,
    session_id: str,
    username: str,
    password: str,
    group_dns: Iterable[str] = (),
    domain: str = "",
) -> list[str]:
    """Bindet alle Freigaben ein, auf die die Verzeichnisgruppen Anspruch geben.

    Rueckgabe: Liste der Meldungen fuer das Protokoll. Fehler einzelner
    Freigaben brechen die Anmeldung **nicht** ab -- der Benutzer soll sich
    anmelden koennen, auch wenn ein Fileserver gerade nicht erreichbar ist.
    """
    extension = core()
    sysops = extension.sysops
    if sysops is None or not sysops.available:
        return ["Systemhelfer nicht verfuegbar -- keine Einbindungen."]

    dns = [dn for dn in group_dns if dn and dn.strip()]
    shares = shares_for_groups(conn, dns)
    if not shares:
        # Stillschweigen war hier der eigentliche Fehler: ob keine Gruppe ankam
        # oder keine passte, liess sich von aussen nicht unterscheiden -- und
        # ein Merkmal, das lautlos nichts tut, ist nicht zu diagnostizieren.
        bekannt = len(list_mappings(conn))
        if bekannt:
            logger.info(
                "Keine Freigabe passend: %d Verzeichnisgruppe(n) der Anmeldung gegen "
                "%d Zuordnung(en). Gruppen: %s",
                len(dns),
                bekannt,
                ", ".join(dns) or "keine",
            )
        return []

    # Ohne Passwort kaeme eine Gast-Einbindung zustande, die still danebengreift:
    # eingebunden, aber leer. Das betrifft die Anmeldung mit zweitem Faktor, bei
    # der das Passwort im zweiten Schritt nicht mehr vorliegt.
    if not password:
        return ["Kein Passwort fuer den Fileserver verfuegbar -- keine Einbindungen."]

    hours = int(extension.settings.get(conn, "fileshare_max_age_hours") or 12)
    root = Path(str(extension.settings.get(conn, "fileshare_mount_root") or "/mnt/tesm-shares"))
    messages: list[str] = []

    for share in shares:
        token = secrets.token_urlsafe(16)
        target = root / token
        result = sysops.mount_cifs(
            str(share["unc"]), str(target), username, password, domain=domain
        )
        if not result.ok:
            messages.append(f"{share['label']}: Einbindung fehlgeschlagen ({result.message})")
            continue
        conn.execute(
            "INSERT INTO fileshare_mounts (token, user_id, session_id, label, unc, mount_path, "
            "created_at, expires_at) VALUES (?,?,?,?,?,?,?,?)",
            (
                token,
                user_id,
                session_id,
                str(share["label"]),
                str(share["unc"]),
                str(target),
                _iso(_now()),
                _iso(_now() + timedelta(hours=hours)),
            ),
        )
        messages.append(f"{share['label']}: eingebunden")
    return messages


def release_mount(conn: sqlite3.Connection, mount_id: int) -> None:
    row = Database.one(conn, "SELECT * FROM fileshare_mounts WHERE id=?", (mount_id,))
    if row is None:
        return
    sysops = core().sysops
    if sysops is not None and sysops.available:
        sysops.umount(str(row["mount_path"]))
    conn.execute(
        "UPDATE fileshare_mounts SET released_at=? WHERE id=?", (_iso(_now()), mount_id)
    )


def release_all_for_user(conn: sqlite3.Connection, user_id: int) -> int:
    count = 0
    for mount in active_mounts(conn, user_id):
        release_mount(conn, mount.id)
        count += 1
    return count


def sweep_expired(database: Any) -> int:
    """Loest abgelaufene Einbindungen -- laeuft im Hintergrundprozess.

    Bewusst datenbankgetrieben: der Vorgaenger durchsuchte beim Start das
    Dateisystem und raeumte dabei auch Einbindungen *anderer, aktiver* Sitzungen
    ab, sobald ein Hilfsskript das Modul importierte.
    """
    released = 0
    with database.session() as conn:
        rows = Database.all(
            conn,
            "SELECT id FROM fileshare_mounts WHERE released_at IS NULL AND expires_at <= ?",
            (_iso(_now()),),
        )
        if not rows:
            return 0
        with database.transaction(conn):
            for row in rows:
                release_mount(conn, int(row["id"]))
                released += 1
    return released


# --------------------------------------------------------------------------- #
# Dateibaum
# --------------------------------------------------------------------------- #


def resolve_path(mount: Mount, relative: str) -> Path:
    """Loest einen relativen Pfad innerhalb einer Einbindung auf.

    Wirft, sobald das Ergebnis das Wurzelverzeichnis verlaesst -- auch ueber
    Symlinks, weil ``resolve()`` diesen folgt.
    """
    candidate = PurePosixPath((relative or "").strip().lstrip("/"))
    if any(part == ".." for part in candidate.parts):
        raise FileshareError("Ungueltiger Pfad.")
    root = mount.path.resolve()
    target = (root / str(candidate)).resolve()
    if target != root and root not in target.parents:
        raise FileshareError("Der Pfad liegt ausserhalb der Freigabe.")
    return target


def list_directory(mount: Mount, relative: str = "") -> dict[str, Any]:
    path = resolve_path(mount, relative)
    if not path.is_dir():
        raise FileshareError("Dieses Verzeichnis existiert nicht.")
    directories: list[dict[str, Any]] = []
    files: list[dict[str, Any]] = []
    try:
        for entry in sorted(path.iterdir(), key=lambda item: item.name.lower()):
            try:
                stat = entry.stat()
            except OSError:
                continue
            record = {
                "name": entry.name,
                "relative": str(PurePosixPath(relative or "") / entry.name).lstrip("/"),
                "size": stat.st_size,
                "modified": datetime.fromtimestamp(stat.st_mtime, timezone.utc).isoformat(
                    timespec="seconds"
                ),
            }
            if entry.is_dir():
                directories.append(record)
            else:
                record["previewable"] = entry.suffix.lower() in PREVIEW_MIME
                files.append(record)
    except OSError as exc:
        raise FileshareError(f"Verzeichnis nicht lesbar ({exc}).") from exc

    parts = [part for part in PurePosixPath(relative or "").parts]
    breadcrumb = []
    walked = PurePosixPath("")
    for part in parts:
        walked = walked / part
        breadcrumb.append({"name": part, "relative": str(walked)})
    return {
        "directories": directories,
        "files": files,
        "breadcrumb": breadcrumb,
        "relative": relative or "",
    }


def preview_mime(name: str) -> str:
    suffix = Path(name).suffix.lower()
    if suffix in PREVIEW_MIME:
        return PREVIEW_MIME[suffix]
    raise FileshareError("Fuer diesen Dateityp gibt es keine Vorschau -- bitte herunterladen.")


def download_mime(name: str) -> str:
    guessed, _ = mimetypes.guess_type(name)
    return guessed or "application/octet-stream"


def safe_filename(name: str) -> str:
    """Verhindert Pfadanteile und versteckte Dateien in Uploads/Neuanlagen."""
    cleaned = os.path.basename((name or "").strip().replace("\\", "/"))
    if not cleaned or cleaned in {".", ".."} or cleaned.startswith("."):
        raise FileshareError("Ungueltiger Dateiname.")
    if len(cleaned) > 200:
        raise FileshareError("Der Dateiname ist zu lang.")
    return cleaned


def delete_entry(mount: Mount, relative: str) -> str:
    path = resolve_path(mount, relative)
    if path == mount.path.resolve():
        raise FileshareError("Das Wurzelverzeichnis der Freigabe kann nicht geloescht werden.")
    if path.is_dir():
        shutil.rmtree(path)
        return "Verzeichnis"
    path.unlink()
    return "Datei"


def make_directory(mount: Mount, relative: str, name: str) -> Path:
    parent = resolve_path(mount, relative)
    target = parent / safe_filename(name)
    if target.exists():
        raise FileshareError("Ein Eintrag mit diesem Namen existiert bereits.")
    target.mkdir()
    return target


def rename_entry(mount: Mount, relative: str, new_name: str) -> Path:
    path = resolve_path(mount, relative)
    target = path.parent / safe_filename(new_name)
    if target.exists():
        raise FileshareError("Ein Eintrag mit diesem Namen existiert bereits.")
    path.rename(target)
    return target
