#!/usr/bin/env python3
"""Minimal same-origin scoreboard API backed by SQLite."""

from __future__ import annotations

import json
import os
import re
import sqlite3
import threading
import time
import unicodedata
from collections import defaultdict, deque
from datetime import datetime, timezone
from http import HTTPStatus
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
from urllib.parse import urlparse

BASE_DIR = Path(__file__).resolve().parent
DB_PATH = Path(os.environ.get("SCORE_DB", "/data/scores.db"))
CATALOG_PATH = Path(
    os.environ.get("QUESTION_CATALOG", BASE_DIR / "question-catalog.json")
)
ALLOWED_ORIGINS = frozenset(
    origin.strip()
    for origin in os.environ.get(
        "ALLOWED_ORIGINS",
        "https://andrestiebitz.de,https://www.andrestiebitz.de",
    ).split(",")
    if origin.strip()
)
MIN_RUN_SECONDS = max(0, int(os.environ.get("MIN_RUN_SECONDS", "30")))
MAX_BODY_BYTES = 8_192
MAX_ROWS = 1_000
ALIAS_PATTERN = re.compile(r"^[\w .-]{2,18}$", re.UNICODE)
RUN_ID_PATTERN = re.compile(r"^[a-zA-Z0-9-]{8,120}$")
MODE_LABELS = {"course": "Kurslauf", "learning": "Lernmodus"}
MODE_COUNTS = {"course": 3, "learning": 10}


def load_catalog(path: Path = CATALOG_PATH) -> dict:
    with path.open("r", encoding="utf-8") as handle:
        catalog = json.load(handle)
    if not isinstance(catalog.get("questions"), dict):
        raise RuntimeError("question catalog is invalid")
    return catalog


CATALOG = load_catalog()


def normalize_alias(value: object) -> str:
    alias = unicodedata.normalize("NFKC", str(value or ""))
    alias = " ".join(alias.split())
    if not ALIAS_PATTERN.fullmatch(alias):
        raise ValueError("Alias: 2–18 Zeichen; Buchstaben, Zahlen, Leerzeichen, . _ -")
    lowered = alias.casefold()
    if any(marker in lowered for marker in ("http", "www.", ".com", ".de", "@")):
        raise ValueError("Im Alias sind keine Links oder Adressen erlaubt")
    return alias


def validate_submission(payload: object) -> dict:
    if not isinstance(payload, dict):
        raise ValueError("JSON-Objekt erwartet")

    alias = normalize_alias(payload.get("alias"))
    run_id = str(payload.get("runId") or "")
    if not RUN_ID_PATTERN.fullmatch(run_id):
        raise ValueError("Ungültige Lauf-ID")

    mode = payload.get("mode")
    if mode not in MODE_COUNTS:
        raise ValueError("Ungültiger Spielmodus")

    run_ids = payload.get("runQuestionIds")
    expected_per_chapter = MODE_COUNTS[mode]
    expected_total = expected_per_chapter * 10
    if (
        not isinstance(run_ids, list)
        or len(run_ids) != expected_total
        or len(set(run_ids)) != expected_total
    ):
        raise ValueError("Der Fragenlauf ist unvollständig")

    chapter_counts: dict[str, int] = defaultdict(int)
    max_xp = 0
    for question_id in run_ids:
        question = CATALOG["questions"].get(question_id)
        if not question:
            raise ValueError("Der Fragenlauf enthält eine unbekannte ID")
        chapter_counts[question["chapterId"]] += 1
        max_xp += int(question["xp"])

    if len(chapter_counts) != 10 or any(
        count != expected_per_chapter for count in chapter_counts.values()
    ):
        raise ValueError("Die Kapitelverteilung ist ungültig")

    outcomes = payload.get("outcomes")
    if not isinstance(outcomes, list) or len(outcomes) != expected_total:
        raise ValueError("Die Einzelergebnisse sind unvollständig")

    outcome_by_id: dict[str, tuple[int, bool]] = {}
    for outcome in outcomes:
        if (
            not isinstance(outcome, list)
            or len(outcome) != 3
            or not isinstance(outcome[0], str)
            or isinstance(outcome[1], bool)
            or not isinstance(outcome[1], int)
            or not 0 <= outcome[1] <= 20
            or not isinstance(outcome[2], bool)
        ):
            raise ValueError("Ein Einzelergebnis ist ungültig")
        if outcome[0] in outcome_by_id:
            raise ValueError("Ein Einzelergebnis ist doppelt vorhanden")
        outcome_by_id[outcome[0]] = (outcome[1], outcome[2])

    if set(outcome_by_id) != set(run_ids):
        raise ValueError("Einzelergebnisse und Fragenlauf passen nicht zusammen")

    xp = 0
    mistakes = 0
    hints = 0
    clean = 0
    for question_id in run_ids:
        wrong_attempts, hint_used = outcome_by_id[question_id]
        question_xp = int(CATALOG["questions"][question_id]["xp"])
        xp += max(
            35,
            question_xp - wrong_attempts * 10 - (15 if hint_used else 0),
        )
        mistakes += wrong_attempts
        hints += int(hint_used)
        clean += int(wrong_attempts == 0 and not hint_used)

    integrity = max(20, 100 - mistakes * 2)
    duration_seconds = payload.get("durationSeconds")
    if (
        isinstance(duration_seconds, bool)
        or not isinstance(duration_seconds, int)
        or not MIN_RUN_SECONDS <= duration_seconds <= 604_800
    ):
        raise ValueError("durationSeconds liegt außerhalb des erlaubten Bereichs")
    score = round((xp / max_xp) * 750 + integrity * 2.5)

    return {
        "run_id": run_id,
        "alias": alias,
        "score": min(1_000, max(0, score)),
        "mode": MODE_LABELS[mode],
        "question_count": expected_total,
        "integrity": integrity,
        "clean": clean,
        "mistakes": mistakes,
        "hints": hints,
        "duration_seconds": duration_seconds,
    }


def connect_database(path: Path = DB_PATH) -> sqlite3.Connection:
    connection = sqlite3.connect(path, timeout=3)
    connection.row_factory = sqlite3.Row
    connection.execute("PRAGMA foreign_keys = ON")
    connection.execute("PRAGMA busy_timeout = 3000")
    return connection


def initialize_database(path: Path = DB_PATH) -> None:
    path.parent.mkdir(parents=True, exist_ok=True)
    with connect_database(path) as connection:
        connection.execute("PRAGMA journal_mode = WAL")
        connection.execute(
            """
            CREATE TABLE IF NOT EXISTS scores (
                id INTEGER PRIMARY KEY,
                run_id TEXT NOT NULL UNIQUE,
                alias TEXT NOT NULL CHECK(length(alias) BETWEEN 2 AND 18),
                score INTEGER NOT NULL CHECK(score BETWEEN 0 AND 1000),
                mode TEXT NOT NULL CHECK(mode IN ('Kurslauf', 'Lernmodus')),
                question_count INTEGER NOT NULL CHECK(question_count IN (30, 100)),
                integrity INTEGER NOT NULL CHECK(integrity BETWEEN 20 AND 100),
                clean INTEGER NOT NULL CHECK(clean BETWEEN 0 AND question_count),
                mistakes INTEGER NOT NULL CHECK(mistakes >= 0),
                hints INTEGER NOT NULL CHECK(hints BETWEEN 0 AND question_count),
                duration_seconds INTEGER NOT NULL CHECK(duration_seconds >= 0),
                created_at TEXT NOT NULL
            )
            """
        )
        connection.execute(
            "CREATE INDEX IF NOT EXISTS scores_rank ON scores(score DESC, created_at ASC)"
        )


def public_entry(row: sqlite3.Row) -> dict:
    return {
        "alias": row["alias"],
        "score": row["score"],
        "mode": row["mode"],
        "questionCount": row["question_count"],
        "integrity": row["integrity"],
        "clean": row["clean"],
        "mistakes": row["mistakes"],
        "hints": row["hints"],
        "createdAt": row["created_at"],
    }


def list_scores(path: Path = DB_PATH, limit: int = 20) -> list[dict]:
    with connect_database(path) as connection:
        rows = connection.execute(
            """
            SELECT alias, score, mode, question_count, integrity, clean,
                   mistakes, hints, created_at
            FROM scores
            ORDER BY score DESC, created_at ASC
            LIMIT ?
            """,
            (min(50, max(1, limit)),),
        ).fetchall()
    return [public_entry(row) for row in rows]


def insert_score(entry: dict, path: Path = DB_PATH) -> tuple[dict, bool]:
    now = datetime.now(timezone.utc).isoformat(timespec="seconds")
    with connect_database(path) as connection:
        existing = connection.execute(
            "SELECT * FROM scores WHERE run_id = ?", (entry["run_id"],)
        ).fetchone()
        if existing:
            return public_entry(existing), True

        connection.execute(
            """
            INSERT INTO scores (
                run_id, alias, score, mode, question_count, integrity,
                clean, mistakes, hints, duration_seconds, created_at
            ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
            """,
            (
                entry["run_id"],
                entry["alias"],
                entry["score"],
                entry["mode"],
                entry["question_count"],
                entry["integrity"],
                entry["clean"],
                entry["mistakes"],
                entry["hints"],
                entry["duration_seconds"],
                now,
            ),
        )
        connection.execute(
            """
            DELETE FROM scores
            WHERE id NOT IN (
                SELECT id FROM scores
                ORDER BY score DESC, created_at DESC
                LIMIT ?
            )
            """,
            (MAX_ROWS,),
        )
        row = connection.execute(
            "SELECT * FROM scores WHERE run_id = ?", (entry["run_id"],)
        ).fetchone()
    return public_entry(row), False


class MemoryRateLimiter:
    def __init__(self, maximum: int = 12, window_seconds: int = 600):
        self.maximum = maximum
        self.window_seconds = window_seconds
        self.requests: dict[str, deque[float]] = defaultdict(deque)
        self.lock = threading.Lock()

    def allow(self, key: str) -> bool:
        now = time.monotonic()
        with self.lock:
            bucket = self.requests[key]
            while bucket and bucket[0] <= now - self.window_seconds:
                bucket.popleft()
            if len(bucket) >= self.maximum:
                return False
            bucket.append(now)
            return True


RATE_LIMITER = MemoryRateLimiter()


class ScoreHandler(BaseHTTPRequestHandler):
    server_version = "KernelPanicScore/1"
    sys_version = ""

    def log_message(self, format_string: str, *args: object) -> None:
        print(f"score-api {self.command} {urlparse(self.path).path}")

    def security_headers(self) -> None:
        self.send_header("Cache-Control", "no-store")
        self.send_header("Content-Security-Policy", "default-src 'none'")
        self.send_header("X-Content-Type-Options", "nosniff")
        self.send_header("Referrer-Policy", "no-referrer")

    def respond(self, status: HTTPStatus, payload: dict) -> None:
        body = json.dumps(payload, ensure_ascii=False, separators=(",", ":")).encode()
        self.send_response(status)
        self.security_headers()
        self.send_header("Content-Type", "application/json; charset=utf-8")
        self.send_header("Content-Length", str(len(body)))
        self.end_headers()
        self.wfile.write(body)

    def do_GET(self) -> None:
        path = urlparse(self.path).path
        if path == "/health":
            try:
                with connect_database() as connection:
                    connection.execute("SELECT 1").fetchone()
                self.respond(HTTPStatus.OK, {"status": "ok"})
            except sqlite3.Error:
                self.respond(HTTPStatus.SERVICE_UNAVAILABLE, {"status": "degraded"})
            return
        if path == "/v1/scores":
            self.respond(HTTPStatus.OK, {"scores": list_scores()})
            return
        self.respond(HTTPStatus.NOT_FOUND, {"error": "not_found"})

    def do_POST(self) -> None:
        if urlparse(self.path).path != "/v1/scores":
            self.respond(HTTPStatus.NOT_FOUND, {"error": "not_found"})
            return
        if self.headers.get("Origin") not in ALLOWED_ORIGINS:
            self.respond(HTTPStatus.FORBIDDEN, {"error": "origin_rejected"})
            return
        if self.headers.get_content_type() != "application/json":
            self.respond(
                HTTPStatus.UNSUPPORTED_MEDIA_TYPE, {"error": "json_required"}
            )
            return

        client_key = self.headers.get("X-Real-IP", self.client_address[0])[:64]
        if not RATE_LIMITER.allow(client_key):
            self.respond(HTTPStatus.TOO_MANY_REQUESTS, {"error": "rate_limited"})
            return

        try:
            content_length = int(self.headers.get("Content-Length", "0"))
        except ValueError:
            content_length = 0
        if not 1 <= content_length <= MAX_BODY_BYTES:
            self.respond(HTTPStatus.REQUEST_ENTITY_TOO_LARGE, {"error": "body_size"})
            return

        try:
            payload = json.loads(self.rfile.read(content_length))
            entry = validate_submission(payload)
            stored, duplicate = insert_score(entry)
        except (UnicodeDecodeError, json.JSONDecodeError):
            self.respond(HTTPStatus.BAD_REQUEST, {"error": "invalid_json"})
            return
        except ValueError as error:
            self.respond(
                HTTPStatus.UNPROCESSABLE_ENTITY,
                {"error": "invalid_score", "message": str(error)},
            )
            return
        except sqlite3.Error:
            self.respond(HTTPStatus.SERVICE_UNAVAILABLE, {"error": "database_busy"})
            return

        self.respond(
            HTTPStatus.OK if duplicate else HTTPStatus.CREATED,
            {"entry": stored, "duplicate": duplicate, "scores": list_scores()},
        )


def main() -> None:
    os.umask(0o077)
    initialize_database()
    server = ThreadingHTTPServer(("0.0.0.0", 8080), ScoreHandler)
    print("score-api ready on :8080")
    server.serve_forever()


if __name__ == "__main__":
    main()
