"""Local Fallenmelder preview server with a minimal Pushover bridge.

The configuration is held in memory only and disappears when the server stops.
Production secrets belong in an encrypted backend secret store.
"""

from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
from urllib.error import HTTPError, URLError
from urllib.parse import parse_qs, urlencode, urlparse
from urllib.request import Request, urlopen
import base64
import ctypes
from ctypes import wintypes
import json
import hashlib
import hmac
import os
import re
import secrets
import mimetypes
import sqlite3
import threading
import time
from datetime import datetime, timedelta, timezone
from http.cookies import SimpleCookie
from zoneinfo import ZoneInfo

ROOT = Path(__file__).resolve().parent
DATA_DIR = Path(os.environ.get("MELDEMEISTER_DATA_DIR", ROOT / ".local"))
SECRET_FILE = DATA_DIR / "pushover.bin"
DATABASE_FILE = DATA_DIR / "meldemeister.db"
UPLOAD_DIR = DATA_DIR / "uploads"
PUSHOVER_URL = "https://api.pushover.net/1/messages.json"
KEY_PATTERN = re.compile(r"^[A-Za-z0-9]{30}$")
pushover_config: dict[str, str] = {}
DEVICE_KEY = os.environ.get("MELDEMEISTER_DEVICE_KEY", "FM-LOCAL-TEST-000001")
SERVER_PORT = int(os.environ.get("MELDEMEISTER_PORT", "5174"))
device_state = {
    "deviceId": "FM-000001",
    "status": "unknown",
    "signal": None,
    "battery": None,
    "firmware": None,
    "lastReportedAt": None,
    "last_trigger_push": 0.0,
    "offline_notified": False,
}
TRIGGER_COOLDOWN_SECONDS = 60
OFFLINE_AFTER_SECONDS = 90
SESSION_COOKIE = "meldemeister_session"
SESSION_DAYS = 30
EMAIL_PATTERN = re.compile(r"^[^\s@]+@[^\s@]+\.[^\s@]+$")


class DataBlob(ctypes.Structure):
    _fields_ = [("cbData", wintypes.DWORD), ("pbData", ctypes.POINTER(ctypes.c_byte))]


def protect_for_windows_user(data: bytes) -> bytes:
    """Encrypt data with Windows DPAPI, bound to the current Windows account."""
    if os.name != "nt":
        raise RuntimeError("Secure local storage is only enabled on Windows")
    source_buffer = ctypes.create_string_buffer(data)
    source = DataBlob(len(data), ctypes.cast(source_buffer, ctypes.POINTER(ctypes.c_byte)))
    result = DataBlob()
    if not ctypes.windll.crypt32.CryptProtectData(
        ctypes.byref(source), "Meldemeister", None, None, None, 0, ctypes.byref(result)
    ):
        raise ctypes.WinError()
    try:
        return ctypes.string_at(result.pbData, result.cbData)
    finally:
        ctypes.windll.kernel32.LocalFree(result.pbData)


def unprotect_for_windows_user(data: bytes) -> bytes:
    source_buffer = ctypes.create_string_buffer(data)
    source = DataBlob(len(data), ctypes.cast(source_buffer, ctypes.POINTER(ctypes.c_byte)))
    result = DataBlob()
    if not ctypes.windll.crypt32.CryptUnprotectData(
        ctypes.byref(source), None, None, None, None, 0, ctypes.byref(result)
    ):
        raise ctypes.WinError()
    try:
        return ctypes.string_at(result.pbData, result.cbData)
    finally:
        ctypes.windll.kernel32.LocalFree(result.pbData)


def save_pushover_config():
    SECRET_FILE.parent.mkdir(parents=True, exist_ok=True)
    plaintext = json.dumps(pushover_config).encode("utf-8")
    if os.name == "nt":
        SECRET_FILE.write_bytes(base64.b64encode(protect_for_windows_user(plaintext)))
    else:
        SECRET_FILE.write_bytes(plaintext)
        SECRET_FILE.chmod(0o600)


def load_pushover_config():
    if not SECRET_FILE.exists():
        return
    try:
        stored = SECRET_FILE.read_bytes()
        if os.name == "nt":
            stored = unprotect_for_windows_user(base64.b64decode(stored))
        loaded = json.loads(stored.decode("utf-8"))
        if KEY_PATTERN.fullmatch(loaded.get("token", "")) and KEY_PATTERN.fullmatch(loaded.get("user", "")):
            pushover_config.update(loaded)
            print("Gespeicherte Pushover-Konfiguration wurde sicher geladen.", flush=True)
    except (OSError, ValueError, json.JSONDecodeError):
        print("Gespeicherte Pushover-Konfiguration konnte nicht geladen werden.", flush=True)


load_pushover_config()


def initialize_database():
    DATABASE_FILE.parent.mkdir(parents=True, exist_ok=True)
    with sqlite3.connect(DATABASE_FILE) as connection:
        connection.execute("""
            CREATE TABLE IF NOT EXISTS status_reports (
                id INTEGER PRIMARY KEY AUTOINCREMENT,
                device_id TEXT NOT NULL,
                status TEXT NOT NULL,
                signal INTEGER,
                battery INTEGER,
                firmware TEXT,
                received_at TEXT NOT NULL
            )
        """)
        connection.execute("""
            CREATE TABLE IF NOT EXISTS users (
                id INTEGER PRIMARY KEY AUTOINCREMENT,
                name TEXT NOT NULL,
                email TEXT NOT NULL UNIQUE,
                password_hash TEXT NOT NULL,
                role TEXT NOT NULL DEFAULT 'owner',
                created_at TEXT NOT NULL
            )
        """)
        connection.execute("""
            CREATE TABLE IF NOT EXISTS sessions (
                token_hash TEXT PRIMARY KEY,
                user_id INTEGER NOT NULL,
                expires_at TEXT NOT NULL,
                created_at TEXT NOT NULL,
                FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
            )
        """)
        connection.execute("""
            CREATE TABLE IF NOT EXISTS territories (
                id INTEGER PRIMARY KEY AUTOINCREMENT,
                name TEXT NOT NULL,
                description TEXT NOT NULL DEFAULT '',
                color TEXT NOT NULL DEFAULT '#79bd82',
                report_morning TEXT NOT NULL DEFAULT '08:00',
                report_evening TEXT NOT NULL DEFAULT '18:00',
                grace_minutes INTEGER NOT NULL DEFAULT 30,
                archived INTEGER NOT NULL DEFAULT 0,
                created_at TEXT NOT NULL,
                updated_at TEXT NOT NULL
            )
        """)
        connection.execute("""
            CREATE TABLE IF NOT EXISTS territory_memberships (
                territory_id INTEGER NOT NULL,
                user_id INTEGER NOT NULL,
                role TEXT NOT NULL,
                created_at TEXT NOT NULL,
                PRIMARY KEY (territory_id, user_id),
                FOREIGN KEY (territory_id) REFERENCES territories(id) ON DELETE CASCADE,
                FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
            )
        """)
        connection.execute("""
            CREATE TABLE IF NOT EXISTS invitations (
                id INTEGER PRIMARY KEY AUTOINCREMENT,
                territory_id INTEGER NOT NULL,
                email TEXT NOT NULL,
                role TEXT NOT NULL,
                status TEXT NOT NULL DEFAULT 'pending',
                token_hash TEXT,
                invited_by INTEGER NOT NULL,
                created_at TEXT NOT NULL,
                UNIQUE (territory_id, email),
                FOREIGN KEY (territory_id) REFERENCES territories(id) ON DELETE CASCADE
            )
        """)
        connection.execute("""
            CREATE TABLE IF NOT EXISTS traps (
                id INTEGER PRIMARY KEY AUTOINCREMENT,
                device_id TEXT NOT NULL UNIQUE,
                name TEXT NOT NULL,
                description TEXT NOT NULL DEFAULT '',
                territory_id INTEGER NOT NULL,
                claim_status TEXT NOT NULL DEFAULT 'active',
                status TEXT NOT NULL DEFAULT 'unknown',
                battery INTEGER,
                signal INTEGER,
                firmware TEXT,
                last_reported_at TEXT,
                maintenance_until TEXT,
                latitude REAL,
                longitude REAL,
                location_label TEXT NOT NULL DEFAULT '',
                created_at TEXT NOT NULL,
                updated_at TEXT NOT NULL,
                FOREIGN KEY (territory_id) REFERENCES territories(id) ON DELETE RESTRICT
            )
        """)
        connection.execute("""
            CREATE TABLE IF NOT EXISTS events (
                id INTEGER PRIMARY KEY AUTOINCREMENT,
                territory_id INTEGER,
                trap_id INTEGER,
                event_type TEXT NOT NULL,
                title TEXT NOT NULL,
                detail TEXT NOT NULL DEFAULT '',
                importance TEXT NOT NULL DEFAULT 'normal',
                actor_user_id INTEGER,
                created_at TEXT NOT NULL,
                FOREIGN KEY (territory_id) REFERENCES territories(id) ON DELETE SET NULL,
                FOREIGN KEY (trap_id) REFERENCES traps(id) ON DELETE SET NULL
            )
        """)
        connection.execute("CREATE INDEX IF NOT EXISTS idx_events_created ON events(created_at DESC)")
        connection.execute("CREATE INDEX IF NOT EXISTS idx_traps_territory ON traps(territory_id)")
        connection.execute("""
            CREATE TABLE IF NOT EXISTS territory_boundaries (
                territory_id INTEGER PRIMARY KEY, geojson TEXT NOT NULL, updated_by INTEGER,
                updated_at TEXT NOT NULL, FOREIGN KEY (territory_id) REFERENCES territories(id) ON DELETE CASCADE
            )
        """)
        connection.execute("""
            CREATE TABLE IF NOT EXISTS territory_items (
                id INTEGER PRIMARY KEY AUTOINCREMENT, territory_id INTEGER NOT NULL,
                item_type TEXT NOT NULL, title TEXT NOT NULL, description TEXT NOT NULL DEFAULT '',
                latitude REAL, longitude REAL, status TEXT NOT NULL DEFAULT 'open',
                priority TEXT NOT NULL DEFAULT 'normal', assigned_to INTEGER, due_at TEXT,
                metadata TEXT NOT NULL DEFAULT '{}', created_by INTEGER NOT NULL, created_at TEXT NOT NULL,
                updated_at TEXT NOT NULL, FOREIGN KEY (territory_id) REFERENCES territories(id) ON DELETE CASCADE
            )
        """)
        connection.execute("CREATE INDEX IF NOT EXISTS idx_territory_items ON territory_items(territory_id, item_type, created_at DESC)")
        connection.execute("""
            CREATE TABLE IF NOT EXISTS media_files (
                id INTEGER PRIMARY KEY AUTOINCREMENT, territory_id INTEGER NOT NULL, item_id INTEGER,
                file_name TEXT NOT NULL, stored_name TEXT NOT NULL UNIQUE, mime_type TEXT NOT NULL,
                file_size INTEGER NOT NULL, media_kind TEXT NOT NULL, uploaded_by INTEGER NOT NULL,
                created_at TEXT NOT NULL, FOREIGN KEY (territory_id) REFERENCES territories(id) ON DELETE CASCADE,
                FOREIGN KEY (item_id) REFERENCES territory_items(id) ON DELETE CASCADE
            )
        """)
        connection.execute("""
            CREATE TABLE IF NOT EXISTS territory_comments (
                id INTEGER PRIMARY KEY AUTOINCREMENT, item_id INTEGER NOT NULL, user_id INTEGER NOT NULL,
                body TEXT NOT NULL, created_at TEXT NOT NULL,
                FOREIGN KEY (item_id) REFERENCES territory_items(id) ON DELETE CASCADE
            )
        """)
        connection.execute("""
            CREATE TABLE IF NOT EXISTS territory_chat (
                id INTEGER PRIMARY KEY AUTOINCREMENT, territory_id INTEGER NOT NULL, user_id INTEGER NOT NULL,
                body TEXT NOT NULL, reply_to INTEGER, created_at TEXT NOT NULL,
                FOREIGN KEY (territory_id) REFERENCES territories(id) ON DELETE CASCADE
            )
        """)
        connection.execute("CREATE TABLE IF NOT EXISTS app_settings (setting_key TEXT PRIMARY KEY, setting_value TEXT NOT NULL)")
        trap_columns = {row[1] for row in connection.execute("PRAGMA table_info(traps)").fetchall()}
        for column, definition in (
            ("latitude", "REAL"),
            ("longitude", "REAL"),
            ("location_label", "TEXT NOT NULL DEFAULT ''"),
        ):
            if column not in trap_columns:
                connection.execute(f"ALTER TABLE traps ADD COLUMN {column} {definition}")
        user_columns = {row[1] for row in connection.execute("PRAGMA table_info(users)").fetchall()}
        for column, definition in (("first_login_at", "TEXT"), ("last_login_at", "TEXT"), ("avatar_media_id", "INTEGER")):
            if column not in user_columns:
                connection.execute(f"ALTER TABLE users ADD COLUMN {column} {definition}")
        territory_columns = {row[1] for row in connection.execute("PRAGMA table_info(territories)").fetchall()}
        if "state" not in territory_columns:
            connection.execute("ALTER TABLE territories ADD COLUMN state TEXT NOT NULL DEFAULT 'Nordrhein-Westfalen'")
        latest = connection.execute("""
            SELECT device_id, status, signal, battery, firmware, received_at
            FROM status_reports ORDER BY id DESC LIMIT 1
        """).fetchone()
    if latest:
        device_state.update({
            "deviceId": latest[0], "status": latest[1], "signal": latest[2],
            "battery": latest[3], "firmware": latest[4], "lastReportedAt": latest[5],
        })


def store_status_report(payload: dict, received_at: str):
    with sqlite3.connect(DATABASE_FILE) as connection:
        connection.execute("""
            INSERT INTO status_reports
                (device_id, status, signal, battery, firmware, received_at)
            VALUES (?, ?, ?, ?, ?, ?)
        """, (
            payload.get("deviceId"), payload.get("status"), payload.get("signal"),
            payload.get("battery"), payload.get("firmware"), received_at,
        ))


def read_history(limit: int = 50) -> list[dict]:
    with sqlite3.connect(DATABASE_FILE) as connection:
        rows = connection.execute("""
            SELECT device_id, status, signal, battery, firmware, received_at
            FROM status_reports ORDER BY id DESC LIMIT ?
        """, (limit,)).fetchall()
    return [
        {"deviceId": row[0], "status": row[1], "signal": row[2], "battery": row[3],
         "firmware": row[4], "receivedAt": row[5]}
        for row in rows
    ]


initialize_database()


def password_hash(password: str) -> str:
    salt = secrets.token_bytes(16)
    iterations = 600_000
    derived = hashlib.pbkdf2_hmac("sha256", password.encode("utf-8"), salt, iterations)
    return f"pbkdf2_sha256${iterations}${salt.hex()}${derived.hex()}"


def password_matches(password: str, encoded: str) -> bool:
    try:
        algorithm, iterations, salt, expected = encoded.split("$", 3)
        if algorithm != "pbkdf2_sha256":
            return False
        derived = hashlib.pbkdf2_hmac(
            "sha256", password.encode("utf-8"), bytes.fromhex(salt), int(iterations)
        )
        return hmac.compare_digest(derived.hex(), expected)
    except (TypeError, ValueError):
        return False


def user_count() -> int:
    with sqlite3.connect(DATABASE_FILE) as connection:
        return int(connection.execute("SELECT COUNT(*) FROM users").fetchone()[0])


def create_session(user_id: int) -> str:
    token = secrets.token_urlsafe(48)
    token_digest = hashlib.sha256(token.encode("utf-8")).hexdigest()
    now = datetime.now(timezone.utc)
    expires_at = now + timedelta(days=SESSION_DAYS)
    with sqlite3.connect(DATABASE_FILE) as connection:
        connection.execute("DELETE FROM sessions WHERE expires_at <= ?", (now.isoformat(),))
        connection.execute(
            "INSERT INTO sessions (token_hash, user_id, expires_at, created_at) VALUES (?, ?, ?, ?)",
            (token_digest, user_id, expires_at.isoformat(), now.isoformat()),
        )
    return token


def session_user(cookie_header: str | None) -> dict | None:
    if not cookie_header:
        return None
    cookie = SimpleCookie()
    try:
        cookie.load(cookie_header)
        token = cookie[SESSION_COOKIE].value
    except (KeyError, AttributeError):
        return None
    token_digest = hashlib.sha256(token.encode("utf-8")).hexdigest()
    now = datetime.now(timezone.utc).isoformat()
    with sqlite3.connect(DATABASE_FILE) as connection:
        row = connection.execute("""
            SELECT users.id, users.name, users.email, users.role
            FROM sessions JOIN users ON users.id = sessions.user_id
            WHERE sessions.token_hash = ? AND sessions.expires_at > ?
        """, (token_digest, now)).fetchone()
    if not row:
        return None
    return {"id": row[0], "name": row[1], "email": row[2], "role": row[3]}


def ensure_initial_domain_data():
    now = datetime.now(timezone.utc).isoformat()
    with sqlite3.connect(DATABASE_FILE) as connection:
        owner = connection.execute("SELECT id FROM users WHERE role = 'owner' ORDER BY id LIMIT 1").fetchone()
        if not owner:
            return
        territory = connection.execute("SELECT id FROM territories ORDER BY id LIMIT 1").fetchone()
        if not territory:
            cursor = connection.execute(
                "INSERT INTO territories (name, description, color, created_at, updated_at) VALUES (?, ?, ?, ?, ?)",
                ("Mein Revier", "Dein erstes Revier", "#79bd82", now, now),
            )
            territory_id = int(cursor.lastrowid)
        else:
            territory_id = int(territory[0])
        connection.execute(
            "INSERT OR IGNORE INTO territory_memberships (territory_id, user_id, role, created_at) VALUES (?, ?, 'owner', ?)",
            (territory_id, int(owner[0]), now),
        )
        existing_trap = connection.execute("SELECT id FROM traps WHERE device_id = 'FM-000001'").fetchone()
        if not existing_trap:
            latest = connection.execute("""
                SELECT status, signal, battery, firmware, received_at
                FROM status_reports WHERE device_id = 'FM-000001' ORDER BY id DESC LIMIT 1
            """).fetchone()
            values = latest or ("unknown", None, None, None, None)
            connection.execute("""
                INSERT INTO traps
                    (device_id, name, territory_id, claim_status, status, signal, battery,
                     firmware, last_reported_at, created_at, updated_at)
                VALUES ('FM-000001', 'Meine Falle', ?, 'active', ?, ?, ?, ?, ?, ?, ?)
            """, (territory_id, values[0], values[1], values[2], values[3], values[4], now, now))


def role_for_territory(user: dict, territory_id: int) -> str | None:
    if user.get("role") == "owner":
        return "owner"
    with sqlite3.connect(DATABASE_FILE) as connection:
        row = connection.execute(
            "SELECT role FROM territory_memberships WHERE territory_id = ? AND user_id = ?",
            (territory_id, user["id"]),
        ).fetchone()
    return row[0] if row else None


def can_manage(user: dict, territory_id: int) -> bool:
    return role_for_territory(user, territory_id) in ("owner", "admin")


def record_event(event_type: str, title: str, detail: str = "", importance: str = "normal",
                 territory_id: int | None = None, trap_id: int | None = None,
                 actor_user_id: int | None = None):
    with sqlite3.connect(DATABASE_FILE) as connection:
        connection.execute("""
            INSERT INTO events
                (territory_id, trap_id, event_type, title, detail, importance, actor_user_id, created_at)
            VALUES (?, ?, ?, ?, ?, ?, ?, ?)
        """, (territory_id, trap_id, event_type, title, detail, importance,
              actor_user_id, datetime.now(timezone.utc).isoformat()))


ensure_initial_domain_data()


def deliver_pushover(title: str, message: str) -> dict:
    data = urlencode({
        "token": pushover_config["token"],
        "user": pushover_config["user"],
        "title": title,
        "message": message,
        "priority": "0",
    }).encode("utf-8")
    request = Request(PUSHOVER_URL, data=data, method="POST")
    with urlopen(request, timeout=10) as response:
        result = json.loads(response.read().decode("utf-8"))
    if result.get("status") != 1:
        raise ValueError("Pushover hat die Nachricht abgelehnt")
    return result


def offline_watchdog():
    while True:
        time.sleep(10)
        reported_at = device_state.get("lastReportedAt")
        if not reported_at or device_state.get("status") == "offline":
            continue
        try:
            last_report = datetime.fromisoformat(reported_at)
            age = (datetime.now(timezone.utc) - last_report).total_seconds()
        except (TypeError, ValueError):
            continue
        if age < OFFLINE_AFTER_SECONDS:
            continue
        offline_at = datetime.now(timezone.utc).isoformat()
        device_state.update({"status": "offline", "lastReportedAt": offline_at})
        store_status_report({
            "deviceId": device_state["deviceId"], "status": "offline",
            "signal": device_state.get("signal"), "battery": device_state.get("battery"),
            "firmware": device_state.get("firmware"),
        }, offline_at)
        with sqlite3.connect(DATABASE_FILE) as connection:
            trap = connection.execute(
                "SELECT id, territory_id, name FROM traps WHERE device_id = ?",
                (device_state["deviceId"],),
            ).fetchone()
            if trap:
                connection.execute(
                    "UPDATE traps SET status='offline', updated_at=? WHERE id=?",
                    (offline_at, trap[0]),
                )
        if trap:
            record_event("trap_offline", "Falle offline", trap[2], "critical", trap[1], trap[0])
        if pushover_config and not device_state["offline_notified"]:
            try:
                deliver_pushover("Falle offline", "Meine Falle hat seit 90 Sekunden keine Statusmeldung gesendet.")
                device_state["offline_notified"] = True
            except (HTTPError, URLError, TimeoutError, ValueError):
                print("Offline-Push konnte nicht gesendet werden.", flush=True)


def run_scheduled_checks_once(local_now: datetime | None = None):
    berlin = ZoneInfo("Europe/Berlin")
    local_now = local_now or datetime.now(berlin)
    with sqlite3.connect(DATABASE_FILE) as connection:
        territories = connection.execute("""
            SELECT id, name, report_morning, report_evening, grace_minutes
            FROM territories WHERE archived = 0
        """).fetchall()
    for territory_id, territory_name, morning, evening, grace in territories:
        for label, scheduled in (("morning", morning), ("evening", evening)):
                hour, minute = (int(part) for part in scheduled.split(":"))
                due = local_now.replace(hour=hour, minute=minute, second=0, microsecond=0)
                evaluate_at = due + timedelta(minutes=int(grace))
                if not evaluate_at <= local_now <= evaluate_at + timedelta(hours=3):
                    continue
                event_key = f"status_check_{territory_id}_{local_now.date().isoformat()}_{label}"
                with sqlite3.connect(DATABASE_FILE) as connection:
                    exists = connection.execute(
                        "SELECT 1 FROM events WHERE event_type = ?", (event_key,)
                    ).fetchone()
                    if exists:
                        continue
                    threshold = (due - timedelta(minutes=30)).astimezone(timezone.utc).isoformat()
                    traps = connection.execute("""
                        SELECT id, name, last_reported_at FROM traps
                        WHERE territory_id = ? AND claim_status = 'active'
                    """, (territory_id,)).fetchall()
                missing = [trap for trap in traps if not trap[2] or trap[2] < threshold]
                if missing:
                    names = ", ".join(trap[1] for trap in missing)
                    record_event(event_key, f"Statusprüfung {scheduled} unvollständig",
                                 f"Keine Meldung von: {names}", "critical", territory_id)
                    with sqlite3.connect(DATABASE_FILE) as connection:
                        connection.executemany(
                            "UPDATE traps SET status='offline', updated_at=? WHERE id=?",
                            [(datetime.now(timezone.utc).isoformat(), trap[0]) for trap in missing],
                        )
                    if pushover_config:
                        try:
                            deliver_pushover("Statusmeldung fehlt",
                                             f"Im Revier {territory_name} fehlt die Statusmeldung von: {names}")
                        except (HTTPError, URLError, TimeoutError, ValueError):
                            print("Statusfenster-Push konnte nicht gesendet werden.", flush=True)
                else:
                    record_event(event_key, f"Statusprüfung {scheduled} erfolgreich",
                                 f"{len(traps)} von {len(traps)} Fallen haben sich gemeldet",
                                 "normal", territory_id)


def scheduled_status_checks():
    while True:
        time.sleep(30)
        run_scheduled_checks_once()


class Handler(SimpleHTTPRequestHandler):
    def __init__(self, *args, **kwargs):
        super().__init__(*args, directory=str(ROOT), **kwargs)

    def log_message(self, fmt, *args):
        # Request paths are useful; request bodies containing secrets are never logged.
        super().log_message(fmt, *args)

    def send_json(self, status: int, payload: dict, cookie: str | None = None):
        body = json.dumps(payload).encode("utf-8")
        self.send_response(status)
        self.send_header("Content-Type", "application/json; charset=utf-8")
        self.send_header("Content-Length", str(len(body)))
        self.send_header("Cache-Control", "no-store")
        if cookie:
            self.send_header("Set-Cookie", cookie)
        self.end_headers()
        self.wfile.write(body)

    def read_json(self, maximum: int = 16_384) -> dict:
        length = int(self.headers.get("Content-Length", "0"))
        if length < 1 or length > maximum:
            raise ValueError("Invalid request size")
        return json.loads(self.rfile.read(length).decode("utf-8"))

    def current_user(self) -> dict | None:
        return session_user(self.headers.get("Cookie"))

    def require_user(self) -> dict | None:
        user = self.current_user()
        if not user:
            self.send_json(401, {"error": "Bitte erneut anmelden"})
        return user

    def session_cookie(self, token: str) -> str:
        max_age = SESSION_DAYS * 24 * 60 * 60
        secure = "; Secure" if self.headers.get("X-Forwarded-Proto") == "https" else ""
        return (
            f"{SESSION_COOKIE}={token}; Path=/; Max-Age={max_age}; "
            f"HttpOnly; SameSite=Strict{secure}"
        )

    def do_GET(self):
        path = urlparse(self.path).path
        if path == "/api/auth/bootstrap":
            user = self.current_user()
            self.send_json(200, {
                "needsSetup": user_count() == 0,
                "authenticated": user is not None,
                "user": user,
            })
            return
        if path == "/api/device/status":
            if not self.require_user():
                return
            public_state = {
                key: value for key, value in device_state.items()
                if key not in ("last_trigger_push", "offline_notified")
            }
            self.send_json(200, public_state)
            return
        if path == "/api/history":
            if not self.require_user():
                return
            self.send_json(200, {"events": read_history()})
            return
        if path.startswith("/api/"):
            user = self.require_user()
            if not user:
                return
            if path == "/api/territories":
                self.list_territories(user)
            elif path == "/api/traps":
                self.list_traps(user)
            elif path == "/api/events":
                self.list_events(user)
            elif path == "/api/members":
                self.list_members(user)
            elif path == "/api/revier":
                self.revier_data(user)
            elif path.startswith("/api/media/"):
                self.serve_media(user, path)
            else:
                self.send_json(404, {"error": "Not found"})
            return
        super().do_GET()

    def do_POST(self):
        try:
            path = urlparse(self.path).path
            payload = self.read_json(15_000_000 if path == "/api/media" else 16_384)
            if path == "/api/auth/register":
                self.register_owner(payload)
            elif path == "/api/auth/login":
                self.login(payload)
            elif path == "/api/auth/join":
                self.join_invitation(payload)
            elif path == "/api/auth/logout":
                self.logout()
            elif path == "/api/device/status":
                self.device_status(payload)
            elif not (user := self.require_user()):
                return
            elif path == "/api/pushover/config":
                self.configure_pushover(payload)
            elif path == "/api/pushover/test":
                self.send_pushover("Meldemeister Test", "Die Push-Verbindung funktioniert.")
            elif path == "/api/territories":
                self.create_territory(user, payload)
            elif path == "/api/territories/update":
                self.update_territory(user, payload)
            elif path == "/api/traps/claim":
                self.claim_trap(user, payload)
            elif path == "/api/traps/update":
                self.update_trap(user, payload)
            elif path == "/api/invitations":
                self.create_invitation(user, payload)
            elif path == "/api/members/update":
                self.update_member(user, payload)
            elif path == "/api/members/remove":
                self.remove_member(user, payload)
            elif path == "/api/invitations/revoke":
                self.revoke_invitation(user, payload)
            elif path == "/api/revier/boundary":
                self.save_boundary(user, payload)
            elif path == "/api/revier/items":
                self.save_revier_item(user, payload)
            elif path == "/api/revier/items/status":
                self.update_revier_item_status(user, payload)
            elif path == "/api/revier/comments":
                self.add_revier_comment(user, payload)
            elif path == "/api/revier/chat":
                self.add_revier_chat(user, payload)
            elif path == "/api/media":
                self.upload_media(user, payload)
            elif path == "/api/territories/archive":
                self.archive_territory(user, payload)
            elif path == "/api/support":
                self.save_support(user, payload)
            else:
                self.send_json(404, {"error": "Not found"})
        except (ValueError, json.JSONDecodeError):
            self.send_json(400, {"error": "Ungültige Anfrage"})
        except (HTTPError, URLError, TimeoutError) as error:
            detail = "Pushover ist nicht erreichbar"
            if isinstance(error, HTTPError):
                try:
                    response = json.loads(error.read().decode("utf-8"))
                    detail = ", ".join(response.get("errors", [])) or detail
                except (json.JSONDecodeError, UnicodeDecodeError):
                    pass
            self.send_json(502, {"error": detail})

    def register_owner(self, payload: dict):
        if user_count() != 0:
            self.send_json(409, {"error": "Das Eigentümer-Konto wurde bereits angelegt"})
            return
        name = str(payload.get("name", "")).strip()
        email = str(payload.get("email", "")).strip().lower()
        password = str(payload.get("password", ""))
        if len(name) < 2 or len(name) > 100:
            self.send_json(400, {"error": "Bitte einen gültigen Namen eingeben"})
            return
        if not EMAIL_PATTERN.fullmatch(email) or len(email) > 254:
            self.send_json(400, {"error": "Bitte eine gültige E-Mail-Adresse eingeben"})
            return
        if len(password) < 12 or len(password) > 200:
            self.send_json(400, {"error": "Das Passwort muss mindestens 12 Zeichen haben"})
            return
        now = datetime.now(timezone.utc).isoformat()
        with sqlite3.connect(DATABASE_FILE) as connection:
            cursor = connection.execute(
                "INSERT INTO users (name, email, password_hash, role, created_at) VALUES (?, ?, ?, 'owner', ?)",
                (name, email, password_hash(password), now),
            )
            user_id = int(cursor.lastrowid)
        ensure_initial_domain_data()
        token = create_session(user_id)
        self.send_json(201, {
            "user": {"id": user_id, "name": name, "email": email, "role": "owner"}
        }, self.session_cookie(token))

    def login(self, payload: dict):
        email = str(payload.get("email", "")).strip().lower()
        password = str(payload.get("password", ""))
        with sqlite3.connect(DATABASE_FILE) as connection:
            row = connection.execute(
                "SELECT id, name, email, password_hash, role FROM users WHERE email = ?",
                (email,),
            ).fetchone()
        if not row or not password_matches(password, row[3]):
            time.sleep(0.35)
            self.send_json(401, {"error": "E-Mail-Adresse oder Passwort ist falsch"})
            return
        now = datetime.now(timezone.utc).isoformat()
        with sqlite3.connect(DATABASE_FILE) as connection:
            first = connection.execute("SELECT first_login_at FROM users WHERE id=?", (row[0],)).fetchone()[0] is None
            connection.execute("UPDATE users SET first_login_at=COALESCE(first_login_at,?), last_login_at=? WHERE id=?", (now, now, row[0]))
            territory_rows = connection.execute("SELECT territory_id FROM territory_memberships WHERE user_id=?", (row[0],)).fetchall()
        if first:
            for territory_row in territory_rows:
                record_event("member_first_login", "Mitglied erstmals angemeldet", row[1], "important", territory_row[0], actor_user_id=row[0])
        token = create_session(int(row[0]))
        self.send_json(200, {
            "user": {"id": row[0], "name": row[1], "email": row[2], "role": row[4]}
        }, self.session_cookie(token))

    def join_invitation(self, payload: dict):
        token = str(payload.get("token", ""))
        token_digest = hashlib.sha256(token.encode("utf-8")).hexdigest()
        name = str(payload.get("name", "")).strip()
        password = str(payload.get("password", ""))
        with sqlite3.connect(DATABASE_FILE) as connection:
            invitation = connection.execute("""
                SELECT id, territory_id, email, role FROM invitations
                WHERE token_hash = ? AND status = 'pending'
            """, (token_digest,)).fetchone()
        if not invitation:
            self.send_json(404, {"error": "Die Einladung ist ungültig oder wurde bereits verwendet"})
            return
        if not 2 <= len(name) <= 100 or not 12 <= len(password) <= 200:
            self.send_json(400, {"error": "Name oder Passwort erfüllt die Anforderungen nicht"})
            return
        now = datetime.now(timezone.utc).isoformat()
        try:
            with sqlite3.connect(DATABASE_FILE) as connection:
                cursor = connection.execute(
                    "INSERT INTO users (name, email, password_hash, role, created_at) VALUES (?, ?, ?, 'member', ?)",
                    (name, invitation[2], password_hash(password), now),
                )
                user_id = int(cursor.lastrowid)
                connection.execute(
                    "INSERT INTO territory_memberships (territory_id, user_id, role, created_at) VALUES (?, ?, ?, ?)",
                    (invitation[1], user_id, invitation[3], now),
                )
                connection.execute("UPDATE invitations SET status='accepted' WHERE id=?", (invitation[0],))
        except sqlite3.IntegrityError:
            self.send_json(409, {"error": "Für diese E-Mail-Adresse besteht bereits ein Konto"})
            return
        record_event("invitation_accepted", "Einladung angenommen", name, "normal",
                     invitation[1], actor_user_id=user_id)
        session_token = create_session(user_id)
        self.send_json(201, {"user": {"id": user_id, "name": name,
                                      "email": invitation[2], "role": "member"}},
                       self.session_cookie(session_token))

    def logout(self):
        cookie_header = self.headers.get("Cookie")
        if cookie_header:
            cookie = SimpleCookie()
            try:
                cookie.load(cookie_header)
                token = cookie[SESSION_COOKIE].value
                digest = hashlib.sha256(token.encode("utf-8")).hexdigest()
                with sqlite3.connect(DATABASE_FILE) as connection:
                    connection.execute("DELETE FROM sessions WHERE token_hash = ?", (digest,))
            except (KeyError, AttributeError):
                pass
        expired = f"{SESSION_COOKIE}=; Path=/; Max-Age=0; HttpOnly; Secure; SameSite=Strict"
        self.send_json(200, {"status": "logged_out"}, expired)

    def list_territories(self, user: dict):
        with sqlite3.connect(DATABASE_FILE) as connection:
            rows = connection.execute("""
                SELECT territories.id, territories.name, territories.description, territories.color,
                       territories.report_morning, territories.report_evening,
                       territories.grace_minutes, territory_memberships.role,
                       COUNT(DISTINCT traps.id), COUNT(DISTINCT tm.user_id)
                       , territories.state
                FROM territories
                JOIN territory_memberships
                  ON territory_memberships.territory_id = territories.id
                 AND territory_memberships.user_id = ?
                LEFT JOIN traps ON traps.territory_id = territories.id
                LEFT JOIN territory_memberships tm ON tm.territory_id = territories.id
                WHERE territories.archived = 0
                GROUP BY territories.id, territory_memberships.role
                ORDER BY territories.name
            """, (user["id"],)).fetchall()
        self.send_json(200, {"territories": [{
            "id": row[0], "name": row[1], "description": row[2], "color": row[3],
            "reportMorning": row[4], "reportEvening": row[5], "graceMinutes": row[6],
            "role": row[7], "trapCount": row[8], "memberCount": row[9], "state": row[10],
        } for row in rows]})

    def create_territory(self, user: dict, payload: dict):
        if user.get("role") != "owner":
            self.send_json(403, {"error": "Nur Eigentümer können Reviere erstellen"})
            return
        name = str(payload.get("name", "")).strip()
        description = str(payload.get("description", "")).strip()[:500]
        state = str(payload.get("state", "Nordrhein-Westfalen")).strip()[:100]
        color = str(payload.get("color", "#79bd82"))
        if len(name) < 2 or len(name) > 100 or not re.fullmatch(r"#[0-9A-Fa-f]{6}", color):
            self.send_json(400, {"error": "Name oder Farbe ist ungültig"})
            return
        now = datetime.now(timezone.utc).isoformat()
        with sqlite3.connect(DATABASE_FILE) as connection:
            cursor = connection.execute(
                "INSERT INTO territories (name, description, color, state, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)",
                (name, description, color, state, now, now),
            )
            territory_id = int(cursor.lastrowid)
            connection.execute(
                "INSERT INTO territory_memberships (territory_id, user_id, role, created_at) VALUES (?, ?, 'owner', ?)",
                (territory_id, user["id"], now),
            )
        record_event("territory_created", "Revier erstellt", name, "normal", territory_id, actor_user_id=user["id"])
        self.send_json(201, {"id": territory_id, "status": "created"})

    def update_territory(self, user: dict, payload: dict):
        try:
            territory_id = int(payload.get("id"))
            grace = int(payload.get("graceMinutes", 30))
        except (TypeError, ValueError):
            self.send_json(400, {"error": "Ungültiges Revier"})
            return
        if not can_manage(user, territory_id):
            self.send_json(403, {"error": "Keine Berechtigung"})
            return
        name = str(payload.get("name", "")).strip()
        description = str(payload.get("description", "")).strip()[:500]
        color = str(payload.get("color", "#79bd82"))
        state = str(payload.get("state", "Nordrhein-Westfalen")).strip()[:100]
        morning = str(payload.get("reportMorning", "08:00"))
        evening = str(payload.get("reportEvening", "18:00"))
        valid_time = r"(?:[01]\d|2[0-3]):[0-5]\d"
        if not (2 <= len(name) <= 100 and re.fullmatch(r"#[0-9A-Fa-f]{6}", color)
                and re.fullmatch(valid_time, morning) and re.fullmatch(valid_time, evening)
                and 5 <= grace <= 180):
            self.send_json(400, {"error": "Bitte die Revierdaten prüfen"})
            return
        with sqlite3.connect(DATABASE_FILE) as connection:
            connection.execute("""
                UPDATE territories SET name=?, description=?, color=?, state=?, report_morning=?,
                    report_evening=?, grace_minutes=?, updated_at=? WHERE id=?
            """, (name, description, color, state, morning, evening, grace,
                  datetime.now(timezone.utc).isoformat(), territory_id))
        record_event("territory_updated", "Revier geändert", name, "normal", territory_id, actor_user_id=user["id"])
        self.send_json(200, {"status": "updated"})

    def list_traps(self, user: dict):
        with sqlite3.connect(DATABASE_FILE) as connection:
            rows = connection.execute("""
                SELECT traps.id, traps.device_id, traps.name, traps.description, traps.territory_id,
                       territories.name, traps.claim_status, traps.status, traps.battery, traps.signal,
                       traps.firmware, traps.last_reported_at, traps.maintenance_until,
                       traps.latitude, traps.longitude, traps.location_label
                FROM traps JOIN territories ON territories.id = traps.territory_id
                JOIN territory_memberships ON territory_memberships.territory_id = traps.territory_id
                WHERE territory_memberships.user_id = ? AND territories.archived = 0
                ORDER BY traps.name
            """, (user["id"],)).fetchall()
        keys = ("id", "deviceId", "name", "description", "territoryId", "territoryName",
                "claimStatus", "status", "battery", "signal", "firmware", "lastReportedAt",
                "maintenanceUntil", "latitude", "longitude", "locationLabel")
        self.send_json(200, {"traps": [dict(zip(keys, row)) for row in rows]})

    def claim_trap(self, user: dict, payload: dict):
        device_id = str(payload.get("deviceId", "")).strip().upper()
        name = str(payload.get("name", "Neue Falle")).strip()
        try:
            territory_id = int(payload.get("territoryId"))
        except (TypeError, ValueError):
            self.send_json(400, {"error": "Bitte ein Revier auswählen"})
            return
        if not can_manage(user, territory_id):
            self.send_json(403, {"error": "Keine Berechtigung"})
            return
        if not re.fullmatch(r"FM-[A-Z0-9-]{6,32}", device_id) or not 2 <= len(name) <= 100:
            self.send_json(400, {"error": "QR-Code oder Name ist ungültig"})
            return
        try:
            latitude = None if payload.get("latitude") in (None, "") else float(payload.get("latitude"))
            longitude = None if payload.get("longitude") in (None, "") else float(payload.get("longitude"))
        except (TypeError, ValueError):
            self.send_json(400, {"error": "Der Standort ist ungültig"})
            return
        if (latitude is None) != (longitude is None) or (latitude is not None and not (-90 <= latitude <= 90 and -180 <= longitude <= 180)):
            self.send_json(400, {"error": "Bitte einen gültigen Standort wählen"})
            return
        location_label = str(payload.get("locationLabel", "")).strip()[:200]
        now = datetime.now(timezone.utc).isoformat()
        try:
            with sqlite3.connect(DATABASE_FILE) as connection:
                cursor = connection.execute("""
                    INSERT INTO traps
                        (device_id, name, territory_id, claim_status, status, latitude, longitude,
                         location_label, created_at, updated_at)
                    VALUES (?, ?, ?, 'pending', 'unknown', ?, ?, ?, ?, ?)
                """, (device_id, name, territory_id, latitude, longitude, location_label, now, now))
                trap_id = int(cursor.lastrowid)
        except sqlite3.IntegrityError:
            self.send_json(409, {"error": "Diese Geräte-ID ist bereits zugeordnet"})
            return
        record_event("trap_claim_started", "Falle per QR-Code hinzugefügt",
                     f"{name} · wartet auf erste Hardwaremeldung", "normal",
                     territory_id, trap_id, user["id"])
        self.send_json(201, {"id": trap_id, "claimStatus": "pending"})

    def update_trap(self, user: dict, payload: dict):
        try:
            trap_id = int(payload.get("id"))
            territory_id = int(payload.get("territoryId"))
        except (TypeError, ValueError):
            self.send_json(400, {"error": "Ungültige Falle"})
            return
        with sqlite3.connect(DATABASE_FILE) as connection:
            current = connection.execute("SELECT territory_id FROM traps WHERE id = ?", (trap_id,)).fetchone()
        if not current or not can_manage(user, int(current[0])) or not can_manage(user, territory_id):
            self.send_json(403, {"error": "Keine Berechtigung"})
            return
        name = str(payload.get("name", "")).strip()
        description = str(payload.get("description", "")).strip()[:500]
        location_label = str(payload.get("locationLabel", "")).strip()[:200]
        latitude = payload.get("latitude")
        longitude = payload.get("longitude")
        try:
            latitude = None if latitude in (None, "") else float(latitude)
            longitude = None if longitude in (None, "") else float(longitude)
        except (TypeError, ValueError):
            self.send_json(400, {"error": "Die Standortkoordinaten sind ungültig"})
            return
        if (latitude is None) != (longitude is None) or (latitude is not None and not (-90 <= latitude <= 90 and -180 <= longitude <= 180)):
            self.send_json(400, {"error": "Bitte einen gültigen Standort wählen"})
            return
        if not 2 <= len(name) <= 100:
            self.send_json(400, {"error": "Bitte einen gültigen Namen eingeben"})
            return
        with sqlite3.connect(DATABASE_FILE) as connection:
            connection.execute(
                "UPDATE traps SET name=?, description=?, territory_id=?, latitude=?, longitude=?, location_label=?, updated_at=? WHERE id=?",
                (name, description, territory_id, latitude, longitude, location_label,
                 datetime.now(timezone.utc).isoformat(), trap_id),
            )
        record_event("trap_updated", "Falle geändert", name, "normal", territory_id, trap_id, user["id"])
        self.send_json(200, {"status": "updated"})

    def list_members(self, user: dict):
        query = parse_qs(urlparse(self.path).query)
        try:
            territory_id = int(query.get("territoryId", [""])[0])
        except ValueError:
            self.send_json(400, {"error": "Ungültiges Revier"})
            return
        if role_for_territory(user, territory_id) is None:
            self.send_json(403, {"error": "Keine Berechtigung"})
            return
        with sqlite3.connect(DATABASE_FILE) as connection:
            members = connection.execute("""
                SELECT users.id, users.name, users.email, territory_memberships.role,
                       users.created_at, users.first_login_at, users.last_login_at
                FROM territory_memberships JOIN users ON users.id = territory_memberships.user_id
                WHERE territory_memberships.territory_id = ? ORDER BY users.name
            """, (territory_id,)).fetchall()
            invitations = connection.execute("""
                SELECT id, email, role, status, created_at FROM invitations
                WHERE territory_id = ? AND status = 'pending' ORDER BY created_at DESC
            """, (territory_id,)).fetchall()
        self.send_json(200, {
            "members": [{"userId": r[0], "name": r[1], "email": r[2], "role": r[3],
                         "joinedAt": r[4], "firstLoginAt": r[5], "lastLoginAt": r[6]} for r in members],
            "invitations": [{"id": r[0], "email": r[1], "role": r[2], "status": r[3], "createdAt": r[4]} for r in invitations],
        })

    def create_invitation(self, user: dict, payload: dict):
        invitation_token = None
        try:
            territory_id = int(payload.get("territoryId"))
        except (TypeError, ValueError):
            self.send_json(400, {"error": "Bitte ein Revier auswählen"})
            return
        if not can_manage(user, territory_id):
            self.send_json(403, {"error": "Keine Berechtigung"})
            return
        email = str(payload.get("email", "")).strip().lower()
        role = str(payload.get("role", "hunter"))
        if not EMAIL_PATTERN.fullmatch(email) or role not in ("admin", "hunter", "guest", "technician"):
            self.send_json(400, {"error": "E-Mail-Adresse oder Gruppe ist ungültig"})
            return
        now = datetime.now(timezone.utc).isoformat()
        with sqlite3.connect(DATABASE_FILE) as connection:
            existing = connection.execute("SELECT id FROM users WHERE email = ?", (email,)).fetchone()
            if existing:
                connection.execute("""
                    INSERT INTO territory_memberships (territory_id, user_id, role, created_at)
                    VALUES (?, ?, ?, ?) ON CONFLICT(territory_id, user_id) DO UPDATE SET role=excluded.role
                """, (territory_id, int(existing[0]), role, now))
                status = "added"
            else:
                invitation_token = secrets.token_urlsafe(32)
                invitation_digest = hashlib.sha256(invitation_token.encode("utf-8")).hexdigest()
                connection.execute("""
                    INSERT INTO invitations
                        (territory_id, email, role, status, token_hash, invited_by, created_at)
                    VALUES (?, ?, ?, 'pending', ?, ?, ?)
                    ON CONFLICT(territory_id, email) DO UPDATE SET
                        role=excluded.role, status='pending', token_hash=excluded.token_hash
                """, (territory_id, email, role, invitation_digest, user["id"], now))
                status = "invited"
        record_event("member_invited", "Mitglied hinzugefügt" if status == "added" else "Mitglied eingeladen",
                     email, "normal", territory_id, actor_user_id=user["id"])
        self.send_json(201, {"status": status, "invitationToken": invitation_token})

    def update_member(self, user: dict, payload: dict):
        try:
            territory_id = int(payload.get("territoryId"))
            member_user_id = int(payload.get("userId"))
        except (TypeError, ValueError):
            self.send_json(400, {"error": "Ungültiges Mitglied"})
            return
        new_role = str(payload.get("role", ""))
        actor_role = role_for_territory(user, territory_id)
        with sqlite3.connect(DATABASE_FILE) as connection:
            current = connection.execute(
                "SELECT role FROM territory_memberships WHERE territory_id=? AND user_id=?",
                (territory_id, member_user_id),
            ).fetchone()
        if not current or actor_role not in ("owner", "admin"):
            self.send_json(403, {"error": "Keine Berechtigung"})
            return
        if current[0] == "owner" or new_role == "owner":
            self.send_json(403, {"error": "Eigentümer-Rechte können hier nicht geändert werden"})
            return
        if actor_role == "admin" and (current[0] == "admin" or new_role == "admin"):
            self.send_json(403, {"error": "Administratoren dürfen keine Administratoren verwalten"})
            return
        if new_role not in ("admin", "hunter", "guest", "technician"):
            self.send_json(400, {"error": "Unbekannte Berechtigungsgruppe"})
            return
        with sqlite3.connect(DATABASE_FILE) as connection:
            connection.execute(
                "UPDATE territory_memberships SET role=? WHERE territory_id=? AND user_id=?",
                (new_role, territory_id, member_user_id),
            )
        record_event("member_role_updated", "Berechtigungsgruppe geändert", new_role,
                     "normal", territory_id, actor_user_id=user["id"])
        self.send_json(200, {"status": "updated"})

    def remove_member(self, user: dict, payload: dict):
        try:
            territory_id = int(payload.get("territoryId"))
            member_user_id = int(payload.get("userId"))
        except (TypeError, ValueError):
            self.send_json(400, {"error": "Ungültiges Mitglied"})
            return
        actor_role = role_for_territory(user, territory_id)
        with sqlite3.connect(DATABASE_FILE) as connection:
            current = connection.execute(
                "SELECT role FROM territory_memberships WHERE territory_id=? AND user_id=?",
                (territory_id, member_user_id),
            ).fetchone()
        if not current or current[0] == "owner" or actor_role not in ("owner", "admin"):
            self.send_json(403, {"error": "Dieser Zugriff kann nicht entfernt werden"})
            return
        if actor_role == "admin" and current[0] == "admin":
            self.send_json(403, {"error": "Administratoren dürfen keine Administratoren entfernen"})
            return
        with sqlite3.connect(DATABASE_FILE) as connection:
            connection.execute(
                "DELETE FROM territory_memberships WHERE territory_id=? AND user_id=?",
                (territory_id, member_user_id),
            )
        record_event("member_removed", "Mitglied entfernt", "Revierzugriff entzogen",
                     "important", territory_id, actor_user_id=user["id"])
        self.send_json(200, {"status": "removed"})

    def revoke_invitation(self, user: dict, payload: dict):
        try:
            invitation_id = int(payload.get("invitationId"))
        except (TypeError, ValueError):
            self.send_json(400, {"error": "Ungültige Einladung"})
            return
        with sqlite3.connect(DATABASE_FILE) as connection:
            invitation = connection.execute(
                "SELECT territory_id, email FROM invitations WHERE id=? AND status='pending'",
                (invitation_id,),
            ).fetchone()
        if not invitation or not can_manage(user, int(invitation[0])):
            self.send_json(403, {"error": "Keine Berechtigung"})
            return
        with sqlite3.connect(DATABASE_FILE) as connection:
            connection.execute(
                "UPDATE invitations SET status='revoked', token_hash=NULL WHERE id=?",
                (invitation_id,),
            )
        record_event("invitation_revoked", "Einladung widerrufen", invitation[1],
                     "normal", invitation[0], actor_user_id=user["id"])
        self.send_json(200, {"status": "revoked"})

    def list_events(self, user: dict):
        query = parse_qs(urlparse(self.path).query)
        important_only = query.get("important", ["true"])[0].lower() != "false"
        with sqlite3.connect(DATABASE_FILE) as connection:
            rows = connection.execute("""
                SELECT events.id, events.event_type, events.title, events.detail, events.importance,
                       events.created_at, territories.name, traps.name
                FROM events
                LEFT JOIN territories ON territories.id = events.territory_id
                LEFT JOIN traps ON traps.id = events.trap_id
                LEFT JOIN territory_memberships ON territory_memberships.territory_id = events.territory_id
                WHERE (events.territory_id IS NULL OR territory_memberships.user_id = ?)
                  AND (? = 0 OR events.importance IN ('important', 'critical'))
                GROUP BY events.id ORDER BY events.created_at DESC LIMIT 100
            """, (user["id"], 1 if important_only else 0)).fetchall()
        keys = ("id", "type", "title", "detail", "importance", "createdAt", "territoryName", "trapName")
        self.send_json(200, {"events": [dict(zip(keys, row)) for row in rows]})

    def revier_data(self, user: dict):
        query = parse_qs(urlparse(self.path).query)
        try:
            territory_id = int(query.get("territoryId", [""])[0])
        except ValueError:
            self.send_json(400, {"error": "Ungültiges Revier"}); return
        if role_for_territory(user, territory_id) is None:
            self.send_json(403, {"error": "Keine Berechtigung"}); return
        with sqlite3.connect(DATABASE_FILE) as connection:
            boundary = connection.execute("SELECT geojson FROM territory_boundaries WHERE territory_id=?", (territory_id,)).fetchone()
            rows = connection.execute("""
                SELECT i.id,i.item_type,i.title,i.description,i.latitude,i.longitude,i.status,i.priority,
                       i.assigned_to,i.due_at,i.metadata,i.created_by,i.created_at,i.updated_at,
                       u.name,assignee.name
                FROM territory_items i JOIN users u ON u.id=i.created_by
                LEFT JOIN users assignee ON assignee.id=i.assigned_to
                WHERE i.territory_id=? ORDER BY i.created_at DESC LIMIT 500
            """, (territory_id,)).fetchall()
            comments = connection.execute("""
                SELECT c.id,c.item_id,c.body,c.created_at,u.name FROM territory_comments c
                JOIN territory_items i ON i.id=c.item_id JOIN users u ON u.id=c.user_id
                WHERE i.territory_id=? ORDER BY c.created_at
            """, (territory_id,)).fetchall()
            chat = connection.execute("""
                SELECT c.id,c.body,c.reply_to,c.created_at,u.name FROM territory_chat c
                JOIN users u ON u.id=c.user_id WHERE c.territory_id=? ORDER BY c.created_at DESC LIMIT 100
            """, (territory_id,)).fetchall()
            media = connection.execute("""
                SELECT m.id,m.item_id,m.file_name,m.mime_type,m.file_size,m.media_kind,m.created_at,u.name
                FROM media_files m JOIN users u ON u.id=m.uploaded_by WHERE m.territory_id=? ORDER BY m.created_at DESC
            """, (territory_id,)).fetchall()
            support = connection.execute("SELECT setting_value FROM app_settings WHERE setting_key='paypal_url'").fetchone()
        keys=("id","type","title","description","latitude","longitude","status","priority","assignedTo","dueAt","metadata","createdBy","createdAt","updatedAt","creatorName","assigneeName")
        items=[]
        for row in rows:
            item=dict(zip(keys,row))
            try: item["metadata"]=json.loads(item["metadata"] or "{}")
            except json.JSONDecodeError: item["metadata"]={}
            item["comments"]=[{"id":c[0],"body":c[2],"createdAt":c[3],"userName":c[4]} for c in comments if c[1]==item["id"]]
            items.append(item)
        self.send_json(200,{"boundary":json.loads(boundary[0]) if boundary else [],"items":items,
            "chat":[{"id":c[0],"body":c[1],"replyTo":c[2],"createdAt":c[3],"userName":c[4]} for c in chat],
            "media":[{"id":m[0],"itemId":m[1],"fileName":m[2],"mimeType":m[3],"fileSize":m[4],"kind":m[5],"createdAt":m[6],"userName":m[7],"url":f"/api/media/{m[0]}"} for m in media],
            "paypalUrl":support[0] if support else ""})

    def _revier_access(self, user, territory_id, write=False):
        role=role_for_territory(user,territory_id)
        return role is not None and (not write or role in ("owner","admin","hunter","technician"))

    def save_boundary(self,user,payload):
        try: territory_id=int(payload.get("territoryId")); points=payload.get("points",[])
        except (TypeError,ValueError): self.send_json(400,{"error":"Ungültige Grenze"}); return
        if not can_manage(user,territory_id): self.send_json(403,{"error":"Nur Besitzer und Administratoren dürfen Grenzen ändern"}); return
        if not isinstance(points,list) or len(points)>200 or any(not isinstance(p,list) or len(p)!=2 for p in points):
            self.send_json(400,{"error":"Ungültige Grenzpunkte"}); return
        now=datetime.now(timezone.utc).isoformat()
        with sqlite3.connect(DATABASE_FILE) as connection:
            connection.execute("INSERT INTO territory_boundaries VALUES(?,?,?,?) ON CONFLICT(territory_id) DO UPDATE SET geojson=excluded.geojson,updated_by=excluded.updated_by,updated_at=excluded.updated_at",(territory_id,json.dumps(points),user["id"],now))
        record_event("boundary_updated","Reviergrenze geändert",f"{len(points)} Grenzpunkte","important",territory_id,actor_user_id=user["id"])
        self.send_json(200,{"status":"saved"})

    def save_revier_item(self,user,payload):
        allowed=("object","task","sighting","harvest","journal","document")
        try: territory_id=int(payload.get("territoryId"))
        except (TypeError,ValueError): self.send_json(400,{"error":"Ungültiges Revier"}); return
        item_type=str(payload.get("type","")); title=str(payload.get("title","")).strip()[:120]
        if item_type not in allowed or not title or not self._revier_access(user,territory_id,True): self.send_json(403,{"error":"Eingabe oder Berechtigung ungültig"}); return
        def number(value):
            try: return None if value in (None,"") else float(value)
            except (TypeError,ValueError): return None
        lat,lon=number(payload.get("latitude")),number(payload.get("longitude"))
        assigned=payload.get("assignedTo"); assigned=int(assigned) if str(assigned).isdigit() else None
        metadata=payload.get("metadata",{}) if isinstance(payload.get("metadata",{}),dict) else {}
        now=datetime.now(timezone.utc).isoformat()
        with sqlite3.connect(DATABASE_FILE) as connection:
            cursor=connection.execute("""INSERT INTO territory_items
                (territory_id,item_type,title,description,latitude,longitude,status,priority,assigned_to,due_at,metadata,created_by,created_at,updated_at)
                VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?)""",(territory_id,item_type,title,str(payload.get("description",""))[:4000],lat,lon,
                str(payload.get("status","open"))[:30],str(payload.get("priority","normal"))[:20],assigned,str(payload.get("dueAt", ""))[:40] or None,
                json.dumps(metadata,ensure_ascii=False),user["id"],now,now)); item_id=cursor.lastrowid
        record_event(f"{item_type}_created",f"{item_type.title()} erstellt",title,"normal",territory_id,actor_user_id=user["id"])
        self.send_json(201,{"id":item_id,"status":"created"})

    def update_revier_item_status(self,user,payload):
        try: item_id=int(payload.get("id"))
        except (TypeError,ValueError): self.send_json(400,{"error":"Ungültiger Eintrag"}); return
        with sqlite3.connect(DATABASE_FILE) as connection: row=connection.execute("SELECT territory_id,title FROM territory_items WHERE id=?",(item_id,)).fetchone()
        if not row or not self._revier_access(user,row[0],True): self.send_json(403,{"error":"Keine Berechtigung"}); return
        status=str(payload.get("status","done"))[:30]
        with sqlite3.connect(DATABASE_FILE) as connection: connection.execute("UPDATE territory_items SET status=?,updated_at=? WHERE id=?",(status,datetime.now(timezone.utc).isoformat(),item_id))
        record_event("item_status","Status geändert",f"{row[1]} · {status}","normal",row[0],actor_user_id=user["id"])
        self.send_json(200,{"status":"updated"})

    def add_revier_comment(self,user,payload):
        try: item_id=int(payload.get("itemId"))
        except (TypeError,ValueError): self.send_json(400,{"error":"Ungültiger Eintrag"}); return
        body=str(payload.get("body","")).strip()[:2000]
        with sqlite3.connect(DATABASE_FILE) as connection: row=connection.execute("SELECT territory_id FROM territory_items WHERE id=?",(item_id,)).fetchone()
        if not row or not body or not self._revier_access(user,row[0],True): self.send_json(403,{"error":"Keine Berechtigung"}); return
        with sqlite3.connect(DATABASE_FILE) as connection: connection.execute("INSERT INTO territory_comments(item_id,user_id,body,created_at) VALUES(?,?,?,?)",(item_id,user["id"],body,datetime.now(timezone.utc).isoformat()))
        self.send_json(201,{"status":"created"})

    def add_revier_chat(self,user,payload):
        try: territory_id=int(payload.get("territoryId"))
        except (TypeError,ValueError): self.send_json(400,{"error":"Ungültiges Revier"}); return
        body=str(payload.get("body","")).strip()[:4000]
        if not body or not self._revier_access(user,territory_id,True): self.send_json(403,{"error":"Keine Berechtigung"}); return
        with sqlite3.connect(DATABASE_FILE) as connection: connection.execute("INSERT INTO territory_chat(territory_id,user_id,body,created_at) VALUES(?,?,?,?)",(territory_id,user["id"],body,datetime.now(timezone.utc).isoformat()))
        self.send_json(201,{"status":"created"})

    def upload_media(self, user, payload):
        try: territory_id=int(payload.get("territoryId"))
        except (TypeError,ValueError): self.send_json(400,{"error":"Ungültiges Revier"}); return
        if not self._revier_access(user,territory_id,True): self.send_json(403,{"error":"Keine Berechtigung"}); return
        file_name=Path(str(payload.get("fileName","Datei"))).name[:180]
        mime=str(payload.get("mimeType",mimetypes.guess_type(file_name)[0] or "application/octet-stream"))[:100]
        allowed=(mime.startswith("image/") or mime in ("application/pdf","application/msword","application/vnd.openxmlformats-officedocument.wordprocessingml.document"))
        if not allowed: self.send_json(400,{"error":"Dieser Dateityp ist nicht erlaubt"}); return
        try: raw=base64.b64decode(str(payload.get("data", "")),validate=True)
        except (ValueError,TypeError): self.send_json(400,{"error":"Datei ist ungültig"}); return
        if not raw or len(raw)>10_000_000: self.send_json(400,{"error":"Datei darf höchstens 10 MB groß sein"}); return
        signatures = {
            "image/jpeg": (b"\xff\xd8\xff",), "image/png": (b"\x89PNG\r\n\x1a\n",),
            "image/webp": (b"RIFF",), "image/gif": (b"GIF87a", b"GIF89a"),
            "application/pdf": (b"%PDF-",), "application/msword": (b"\xd0\xcf\x11\xe0",),
            "application/vnd.openxmlformats-officedocument.wordprocessingml.document": (b"PK\x03\x04",),
        }
        if mime in signatures and not any(raw.startswith(signature) for signature in signatures[mime]):
            self.send_json(400,{"error":"Dateiinhalt und Dateityp stimmen nicht überein"}); return
        item_id=payload.get("itemId"); item_id=int(item_id) if str(item_id).isdigit() else None
        if item_id:
            with sqlite3.connect(DATABASE_FILE) as connection: item=connection.execute("SELECT territory_id FROM territory_items WHERE id=?",(item_id,)).fetchone()
            if not item or item[0]!=territory_id: self.send_json(400,{"error":"Eintrag gehört nicht zum Revier"}); return
        UPLOAD_DIR.mkdir(parents=True,exist_ok=True)
        stored=f"{secrets.token_hex(20)}{Path(file_name).suffix.lower()[:10]}"; (UPLOAD_DIR/stored).write_bytes(raw)
        now=datetime.now(timezone.utc).isoformat(); kind="image" if mime.startswith("image/") else "document"
        with sqlite3.connect(DATABASE_FILE) as connection:
            cursor=connection.execute("INSERT INTO media_files(territory_id,item_id,file_name,stored_name,mime_type,file_size,media_kind,uploaded_by,created_at) VALUES(?,?,?,?,?,?,?,?,?)",(territory_id,item_id,file_name,stored,mime,len(raw),kind,user["id"],now)); media_id=cursor.lastrowid
        record_event("image_added" if kind=="image" else "document_uploaded","Bild hinzugefügt" if kind=="image" else "Dokument hochgeladen",file_name,"normal",territory_id,actor_user_id=user["id"])
        self.send_json(201,{"id":media_id,"url":f"/api/media/{media_id}"})

    def serve_media(self,user,path):
        try: media_id=int(path.rsplit("/",1)[1])
        except ValueError: self.send_json(404,{"error":"Nicht gefunden"}); return
        with sqlite3.connect(DATABASE_FILE) as connection:
            row=connection.execute("SELECT territory_id,file_name,stored_name,mime_type,file_size FROM media_files WHERE id=?",(media_id,)).fetchone()
        if not row or role_for_territory(user,row[0]) is None: self.send_json(404,{"error":"Nicht gefunden"}); return
        target=UPLOAD_DIR/row[2]
        if not target.is_file(): self.send_json(404,{"error":"Datei fehlt"}); return
        body=target.read_bytes(); self.send_response(200); self.send_header("Content-Type",row[3]); self.send_header("Content-Length",str(len(body))); self.send_header("Content-Disposition",f"inline; filename*=UTF-8''{urlencode({'':row[1]})[1:]}"); self.send_header("Cache-Control","private, max-age=3600"); self.end_headers(); self.wfile.write(body)

    def archive_territory(self,user,payload):
        try: territory_id=int(payload.get("territoryId"))
        except (TypeError,ValueError): self.send_json(400,{"error":"Ungültiges Revier"}); return
        role=role_for_territory(user,territory_id)
        with sqlite3.connect(DATABASE_FILE) as connection: territory=connection.execute("SELECT name FROM territories WHERE id=? AND archived=0",(territory_id,)).fetchone()
        if not territory or role not in ("owner","admin"): self.send_json(403,{"error":"Keine Berechtigung"}); return
        if str(payload.get("confirmation",""))!=territory[0]: self.send_json(400,{"error":"Zur Bestätigung den vollständigen Reviernamen eingeben"}); return
        record_event("territory_archived","Revier gelöscht",territory[0],"critical",territory_id,actor_user_id=user["id"])
        with sqlite3.connect(DATABASE_FILE) as connection: connection.execute("UPDATE territories SET archived=1,updated_at=? WHERE id=?",(datetime.now(timezone.utc).isoformat(),territory_id))
        self.send_json(200,{"status":"archived"})

    def save_support(self,user,payload):
        if user.get("role") != "owner": self.send_json(403,{"error":"Nur der Eigentümer kann den PayPal-Link ändern"}); return
        url=str(payload.get("paypalUrl","")).strip()[:500]
        if not re.fullmatch(r"https://(?:(?:www\.)?paypal\.com/paypalme|paypal\.me)/[A-Za-z0-9._-]+/?",url):
            self.send_json(400,{"error":"Bitte einen vollständigen PayPal.Me-Link eingeben"}); return
        with sqlite3.connect(DATABASE_FILE) as connection: connection.execute("INSERT INTO app_settings VALUES('paypal_url',?) ON CONFLICT(setting_key) DO UPDATE SET setting_value=excluded.setting_value",(url,))
        self.send_json(200,{"status":"saved"})

    def configure_pushover(self, payload: dict):
        token = str(payload.get("appToken", ""))
        user = str(payload.get("userKey", ""))
        if not KEY_PATTERN.fullmatch(token) or not KEY_PATTERN.fullmatch(user):
            self.send_json(400, {"error": "Token und User Key müssen 30 Zeichen haben"})
            return
        pushover_config.clear()
        pushover_config.update({
            "token": token,
            "user": user,
            "ownerName": str(payload.get("ownerName", "Eigentümer"))[:100],
            "territory": str(payload.get("territory", "Mein Revier"))[:100],
        })
        save_pushover_config()
        self.send_json(200, {"status": "configured"})

    def send_pushover(self, title: str, message: str):
        if not pushover_config:
            self.send_json(409, {"error": "Pushover ist noch nicht gespeichert"})
            return
        try:
            result = deliver_pushover(title, message)
        except ValueError as error:
            self.send_json(502, {"error": str(error)})
            return
        self.send_json(200, {"status": "sent", "request": result.get("request")})

    def device_status(self, payload: dict):
        if self.headers.get("X-Device-Key") != DEVICE_KEY:
            self.send_json(401, {"error": "Geräteschlüssel ungültig"})
            return
        device_id = str(payload.get("deviceId", "")).strip().upper()
        with sqlite3.connect(DATABASE_FILE) as connection:
            trap = connection.execute("""
                SELECT traps.id, traps.name, traps.territory_id, territories.name,
                       traps.status, traps.claim_status
                FROM traps JOIN territories ON territories.id = traps.territory_id
                WHERE traps.device_id = ?
            """, (device_id,)).fetchone()
        if not trap:
            self.send_json(404, {"error": "Gerät nicht bekannt"})
            return
        status = payload.get("status")
        if status not in ("ready", "triggered"):
            self.send_json(400, {"error": "Status ist ungültig"})
            return
        previous_status = trap[4]
        received_at = datetime.now(timezone.utc).isoformat()
        with sqlite3.connect(DATABASE_FILE) as connection:
            connection.execute("""
                UPDATE traps SET claim_status='active', status=?, signal=?, battery=?, firmware=?,
                    last_reported_at=?, updated_at=? WHERE id=?
            """, (status, payload.get("signal"), payload.get("battery"), payload.get("firmware"),
                  received_at, received_at, trap[0]))
        store_status_report(payload, received_at)
        if device_id == device_state["deviceId"]:
            device_state.update({
                "status": status, "signal": payload.get("signal"),
                "battery": payload.get("battery"), "firmware": payload.get("firmware"),
                "lastReportedAt": received_at, "offline_notified": False,
            })
        if trap[5] == "pending":
            record_event("trap_activated", "Falle verbunden", f"{trap[1]} ist jetzt aktiv",
                         "important", trap[2], trap[0])
        if previous_status == "offline":
            record_event("trap_online", "Falle wieder online", trap[1], "important", trap[2], trap[0])
        if status == "ready" and previous_status == "triggered":
            record_event("trap_reset", "Falle zurückgesetzt", trap[1], "important", trap[2], trap[0])
        now = time.monotonic()
        may_notify = now - device_state["last_trigger_push"] >= TRIGGER_COOLDOWN_SECONDS
        if status == "triggered" and previous_status != "triggered" and may_notify:
            device_state["last_trigger_push"] = now
            record_event("trap_triggered", "Falle ausgelöst", trap[1], "critical", trap[2], trap[0])
            self.send_pushover(
                "Falle ausgelöst",
                f"{trap[1]} im Revier {trap[3]} wurde ausgelöst.",
            )
            return
        self.send_json(200, {"status": "received", "deviceStatus": status})


if __name__ == "__main__":
    print(f"Meldemeister läuft auf http://127.0.0.1:{SERVER_PORT}/preview.html", flush=True)
    threading.Thread(target=offline_watchdog, daemon=True).start()
    threading.Thread(target=scheduled_status_checks, daemon=True).start()
    ThreadingHTTPServer(("0.0.0.0", SERVER_PORT), Handler).serve_forever()
