import configparser
import gzip
import json
import logging
import os
import re
import sys
from contextlib import asynccontextmanager
from datetime import datetime, timedelta, timezone
from typing import List, Literal, Optional

import jwt
import pymysql
import pymysql.cursors
from fastapi import APIRouter, Body, Depends, FastAPI, HTTPException, Path, Query, Request, status
from fastapi.responses import FileResponse
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer, OAuth2PasswordRequestForm
from passlib.context import CryptContext
from pydantic import BaseModel, Field, root_validator, validator

CONFIG_FILE = os.path.join(os.path.dirname(__file__), "target.conf")

config = configparser.ConfigParser()
if not os.path.exists(CONFIG_FILE):
    print(f"Konfigurationsdatei nicht gefunden: {CONFIG_FILE}", file=sys.stderr)
    sys.exit(1)
config.read(CONFIG_FILE)

# Logging
_level = getattr(logging, config.get("logging", "level", fallback="INFO").upper())
_log_file = config.get("logging", "file", fallback=None)
_handlers = [logging.StreamHandler()]
if _log_file:
    _handlers.append(logging.FileHandler(
        os.path.join(os.path.dirname(__file__), _log_file)
    ))
logging.basicConfig(
    level=_level,
    format="%(asctime)s %(levelname)s %(message)s",
    handlers=_handlers,
)

# Konfiguration
SECRET_KEY = config.get("api", "secret_key")
ALGORITHM = "HS256"
TOKEN_EXPIRE_MINUTES = config.getint("api", "token_expire_minutes", fallback=60)
OWN_TOKEN = config.get("sync", "own_token")
SOURCE_TOKEN = config.get("sync", "source_token")
ADMIN_INITIAL_PASSWORD = "erhkm-673BX#a"
PASSWORD_PATTERN = re.compile(r"^(?=.*[A-Za-z])(?=.*\d).{10,}$")

# Erlaubte Zeichen in Textfeldern: Buchstaben (inkl. Umlaute), Ziffern,
# Leerzeichen, Bindestrich, Punkt, Unterstrich, Schrägstrich.
# Explizit verboten: ' " ; | & : , < > ( ) = \ { } [ ] * $ @ ! # % ^ ~ `
_SAFE_TEXT_RE = re.compile(r"^[a-zA-Z0-9äöüÄÖÜß ._/-]*$")
_SAFE_LOGIN_RE = re.compile(r"^[a-zA-Z0-9_.-]+$")

pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
bearer_scheme = HTTPBearer()

STATE_FILE = os.path.join(os.path.dirname(__file__), "target.state")

_last_connection_time: Optional[datetime] = None
_last_sync_time: Optional[datetime] = None


def _load_state():
    global _last_connection_time, _last_sync_time
    if not os.path.exists(STATE_FILE):
        return
    try:
        with open(STATE_FILE) as f:
            data = json.load(f)
        if data.get("last_connection"):
            _last_connection_time = datetime.fromisoformat(data["last_connection"])
        if data.get("last_sync"):
            _last_sync_time = datetime.fromisoformat(data["last_sync"])
    except Exception:
        logging.warning("target.state konnte nicht geladen werden")


def _save_state():
    try:
        with open(STATE_FILE, "w") as f:
            json.dump({
                "last_connection": _last_connection_time.isoformat() if _last_connection_time else None,
                "last_sync":       _last_sync_time.isoformat()       if _last_sync_time       else None,
            }, f)
    except Exception:
        logging.warning("target.state konnte nicht gespeichert werden")


# ---------------------------------------------------------------------------
# Datenbankverbindung
# ---------------------------------------------------------------------------

def _db_connect():
    return pymysql.connect(
        host=config.get("database", "host"),
        port=config.getint("database", "port", fallback=3306),
        user=config.get("database", "user"),
        password=config.get("database", "password"),
        database=config.get("database", "database"),
        charset="utf8",
        cursorclass=pymysql.cursors.DictCursor,
        connect_timeout=10,
    )


def get_db():
    conn = _db_connect()
    try:
        yield conn
    finally:
        conn.close()


# ---------------------------------------------------------------------------
# Schema-Erstellung beim Start
# ---------------------------------------------------------------------------

def ensure_schema(conn):
    with conn.cursor() as cur:
        cur.execute("""
            CREATE TABLE IF NOT EXISTS `Scheiben` (
              `ScheibenID` int(11) NOT NULL,
              `Starterliste` varchar(36) NOT NULL,
              `StarterlistenID` int(11) NOT NULL,
              `Startzeit` datetime NOT NULL,
              `StartNr` int(11) NOT NULL,
              `Nachname` varchar(36) NOT NULL,
              `Vorname` varchar(36) NOT NULL,
              `Geschlecht` enum('male','female','unknown') NOT NULL DEFAULT 'unknown',
              `Land` varchar(3) NOT NULL,
              `SportpassID` bigint(20) NOT NULL,
              `StandNr` smallint(6) NOT NULL,
              `StandNrText` varchar(36) NOT NULL,
              `Disziplin` varchar(36) NOT NULL,
              `DisziplinID` int(11) NOT NULL,
              `Klasse` varchar(36) NOT NULL,
              `KlassenID` int(11) NOT NULL,
              `Verein` varchar(36) NOT NULL,
              `VereinsID` int(11) NOT NULL,
              `Mannschaft` varchar(36) NOT NULL,
              `MannschaftsID` int(11) NOT NULL,
              `Rangliste` varchar(36) NOT NULL,
              `RanglistenID` int(11) NOT NULL,
              `Gruppe` varchar(36) NOT NULL,
              `GruppenID` int(11) NOT NULL,
              `Status` varchar(72) NOT NULL,
              `Trefferzahl` smallint(6) NOT NULL,
              `TotalRing` int(11) NOT NULL,
              `TotalRing01` int(11) NOT NULL,
              `BesterTeiler01` int(11) NOT NULL,
              `Zeitstempel` datetime NOT NULL,
              PRIMARY KEY (`ScheibenID`)
            ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb3 COLLATE=utf8mb3_unicode_ci
        """)
        cur.execute("""
            CREATE TABLE IF NOT EXISTS `Serien` (
              `ScheibenID` int(11) NOT NULL,
              `Stellung` smallint(6) NOT NULL,
              `Serie` smallint(6) NOT NULL,
              `Ring` int(11) NOT NULL,
              `Ring01` int(11) NOT NULL,
              PRIMARY KEY (`ScheibenID`, `Stellung`, `Serie`),
              CONSTRAINT `Serien_ibfk_1` FOREIGN KEY (`ScheibenID`)
                REFERENCES `Scheiben` (`ScheibenID`) ON DELETE CASCADE ON UPDATE CASCADE
            ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb3 COLLATE=utf8mb3_unicode_ci
        """)
        cur.execute("""
            CREATE TABLE IF NOT EXISTS `Treffer` (
              `ScheibenID` int(11) NOT NULL,
              `Stellung` smallint(6) NOT NULL,
              `Treffer` smallint(6) NOT NULL,
              `Serie` smallint(6) NOT NULL,
              `x` int(11) NOT NULL,
              `y` int(11) NOT NULL,
              `Innenzehner` tinyint(1) NOT NULL,
              `Wertung` int(11) NOT NULL,
              `Text` varchar(36) NOT NULL,
              `Ring` int(11) NOT NULL,
              `Ring01` int(11) NOT NULL,
              `Teiler01` int(11) NOT NULL,
              `Teiler001` int(11) NOT NULL,
              `Zeitstempel` datetime NOT NULL,
              `Millisekunden` smallint(6) NOT NULL,
              PRIMARY KEY (`ScheibenID`, `Stellung`, `Treffer`),
              CONSTRAINT `Treffer_ibfk_1` FOREIGN KEY (`ScheibenID`)
                REFERENCES `Scheiben` (`ScheibenID`) ON DELETE CASCADE ON UPDATE CASCADE
            ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb3 COLLATE=utf8mb3_unicode_ci
        """)
        cur.execute("""
            CREATE TABLE IF NOT EXISTS `Version` (
              `Versionsnummer` int(11) NOT NULL
            ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb3 COLLATE=utf8mb3_unicode_ci
        """)
        cur.execute("""
            CREATE TABLE IF NOT EXISTS `Users` (
              `Login` varchar(50) NOT NULL,
              `Password` varchar(255) NOT NULL,
              `Role` enum('admin','user','restricted','singe') NOT NULL DEFAULT 'user',
              `Nachname` varchar(50) NOT NULL DEFAULT '',
              `Vorname` varchar(50) NOT NULL DEFAULT '',
              `SportpassID` bigint(20) NOT NULL DEFAULT 0,
              PRIMARY KEY (`Login`)
            ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb3 COLLATE=utf8mb3_unicode_ci
        """)
        conn.commit()

        # Migration: IsAdmin (alt) → Role (neu)
        try:
            cur.execute(
                "ALTER TABLE Users ADD COLUMN "
                "`Role` enum('admin','user','restricted') NOT NULL DEFAULT 'user'"
            )
            cur.execute("UPDATE Users SET Role='admin' WHERE IsAdmin=1")
            cur.execute("ALTER TABLE Users DROP COLUMN IsAdmin")
            conn.commit()
            logging.info("Users-Tabelle migriert: IsAdmin → Role")
        except Exception:
            pass  # Spalte existiert bereits oder IsAdmin nicht vorhanden

        # Migration: 'singe' zur Role-Enum hinzufügen
        try:
            cur.execute(
                "ALTER TABLE Users MODIFY COLUMN "
                "`Role` enum('admin','user','restricted','singe') NOT NULL DEFAULT 'user'"
            )
            conn.commit()
            logging.info("Users-Tabelle migriert: Role-Enum um 'singe' erweitert")
        except Exception:
            pass

        # Migration: Nachname, Vorname, SportpassID für Singe-Benutzer
        for _col, _defn in [
            ("Nachname",    "varchar(50) NOT NULL DEFAULT ''"),
            ("Vorname",     "varchar(50) NOT NULL DEFAULT ''"),
            ("SportpassID", "bigint(20) NOT NULL DEFAULT 0"),
        ]:
            try:
                cur.execute(f"ALTER TABLE Users ADD COLUMN `{_col}` {_defn}")
                conn.commit()
                logging.info("Users-Tabelle: Spalte '%s' hinzugefügt", _col)
            except Exception:
                pass

        # Admin-User anlegen
        cur.execute("SELECT Login FROM Users WHERE Login = 'admin'")
        if not cur.fetchone():
            cur.execute(
                "INSERT INTO Users (Login, Password, Role) VALUES ('admin', %s, 'admin')",
                (pwd_context.hash(ADMIN_INITIAL_PASSWORD),),
            )
            conn.commit()
            logging.info("Admin-User angelegt")

        # Standard-User anlegen
        cur.execute("SELECT Login FROM Users WHERE Login = 'My'")
        if not cur.fetchone():
            cur.execute(
                "INSERT INTO Users (Login, Password, Role) VALUES ('My', %s, 'restricted')",
                (pwd_context.hash("wsd-78cvbmXE3"),),
            )
            conn.commit()
            logging.info("Standard-User 'My' angelegt")

    logging.info("Datenbankschema geprüft/erstellt")


@asynccontextmanager
async def lifespan(app: FastAPI):
    conn = _db_connect()
    try:
        ensure_schema(conn)
    finally:
        conn.close()
    _load_state()
    logging.info("meyton-sync Target gestartet")
    yield


_TAGS_METADATA = [
    {"name": "Authentifizierung",  "description": "Login und Token-Verwaltung"},
    {"name": "Scheiben",           "description": "Abfrage von Scheiben, Serien und Treffern"},
    {"name": "Benutzerverwaltung", "description": "Benutzerverwaltung (nur Admin)"},
    {"name": "Datenbank",          "description": "Datenbank-Operationen (nur Admin)"},
]

app = FastAPI(
    title="meyton-sync API",
    description="Synchronisierungs- und Abfrage-API für die Schießanlage ISM 1890",
    version="1.0.0",
    lifespan=lifespan,
    root_path="/meyton",
    openapi_tags=_TAGS_METADATA,
)


class _GzipRequestMiddleware:
    """Reines ASGI-Middleware: ersetzt den receive-Callable durch dekomprimierte Daten."""

    def __init__(self, app):
        self.app = app

    async def __call__(self, scope, receive, send):
        if scope["type"] != "http":
            await self.app(scope, receive, send)
            return

        content_encoding = b""
        for key, value in scope.get("headers", []):
            if key.lower() == b"content-encoding":
                content_encoding = value.lower()
                break

        if content_encoding != b"gzip":
            await self.app(scope, receive, send)
            return

        chunks = []
        more_body = True
        while more_body:
            message = await receive()
            chunks.append(message.get("body", b""))
            more_body = message.get("more_body", False)

        try:
            decompressed = gzip.decompress(b"".join(chunks))
        except OSError:
            await send({
                "type": "http.response.start",
                "status": 400,
                "headers": [(b"content-type", b"text/plain; charset=utf-8")],
            })
            await send({"type": "http.response.body", "body": b"Invalid gzip data"})
            return

        new_headers = [
            (k, v) for k, v in scope["headers"]
            if k.lower() not in (b"content-encoding", b"content-length")
        ]
        new_headers.append((b"content-length", str(len(decompressed)).encode("latin-1")))
        new_scope = {**scope, "headers": new_headers}

        body_sent = False

        async def decompressed_receive():
            nonlocal body_sent
            if not body_sent:
                body_sent = True
                return {"type": "http.request", "body": decompressed, "more_body": False}
            return {"type": "http.request", "body": b"", "more_body": False}

        await self.app(new_scope, decompressed_receive, send)


app.add_middleware(_GzipRequestMiddleware)
api_router = APIRouter(prefix="/api")


@app.exception_handler(Exception)
async def _log_response_validation_errors(request: Request, exc: Exception):
    from fastapi.exceptions import ResponseValidationError
    from fastapi.responses import JSONResponse
    if isinstance(exc, ResponseValidationError):
        logging.error("ResponseValidationError bei %s: %s", request.url.path, exc.errors())
        return JSONResponse(status_code=500, content={"detail": "Internal Server Error"})
    raise exc


# ---------------------------------------------------------------------------
# Authentifizierungs-Hilfsfunktionen
# ---------------------------------------------------------------------------

def _verify_sync_token(request: Request):
    auth = request.headers.get("Authorization", "")
    if not auth.startswith("Bearer "):
        raise HTTPException(status_code=401, detail="Sync-Token fehlt")
    if auth.removeprefix("Bearer ") != SOURCE_TOKEN:
        raise HTTPException(status_code=401, detail="Ungültiger Sync-Token")


def _create_jwt(login: str, role: str, **extra) -> str:
    expire = datetime.now(timezone.utc) + timedelta(minutes=TOKEN_EXPIRE_MINUTES)
    payload = {"sub": login, "role": role, "exp": expire}
    payload.update(extra)
    return jwt.encode(payload, SECRET_KEY, algorithm=ALGORITHM)


def _decode_jwt(credentials: HTTPAuthorizationCredentials = Depends(bearer_scheme)) -> dict:
    try:
        payload = jwt.decode(
            credentials.credentials, SECRET_KEY, algorithms=[ALGORITHM]
        )
        if not payload.get("sub"):
            raise HTTPException(status_code=401, detail="Ungültiger Token")
        return payload
    except jwt.ExpiredSignatureError:
        raise HTTPException(status_code=401, detail="Token abgelaufen")
    except jwt.InvalidTokenError:
        raise HTTPException(status_code=401, detail="Ungültiger Token")


def require_login(payload: dict = Depends(_decode_jwt)) -> dict:
    return payload


def require_full_access(payload: dict = Depends(_decode_jwt)) -> str:
    """Erlaubt Rollen 'admin' und 'user', nicht 'restricted'."""
    if payload.get("role") not in ("admin", "user"):
        raise HTTPException(status_code=403, detail="Keine Berechtigung für diesen Endpunkt")
    return payload["sub"]


def require_admin(payload: dict = Depends(_decode_jwt)) -> str:
    if payload.get("role") != "admin":
        raise HTTPException(status_code=403, detail="Keine Admin-Rechte")
    return payload["sub"]



# ---------------------------------------------------------------------------
# Pydantic-Modelle
# ---------------------------------------------------------------------------

def _safe_text(v: str, field_name: str) -> str:
    if not _SAFE_TEXT_RE.match(v):
        raise ValueError(f"{field_name} enthält unerlaubte Sonderzeichen")
    return v


# Sync-Modelle: nur Typ- und Längenprüfung, keine Zeichenvalidierung.
# Der Sync-Endpunkt ist durch Pre-shared Token gesichert — die Daten kommen
# von einem vertrauenswürdigen System und können beliebige Zeichen enthalten.
_INT11 = {"ge": -2_147_483_648, "le": 2_147_483_647}   # int(11) signed
_SM6   = {"ge": -32768,        "le": 32767}             # smallint(6) signed

class ScheibeIn(BaseModel):
    ScheibenID:      int = Field(..., **_INT11)
    Starterliste:    str = Field(..., max_length=36)
    StarterlistenID: int = Field(..., **_INT11)
    Startzeit:       str
    StartNr:         int = Field(..., **_INT11)
    Nachname:        str = Field(..., max_length=36)
    Vorname:         str = Field(..., max_length=36)
    Geschlecht:      str = Field(..., regex=r"^(male|female|unknown)$")
    Land:            str = Field(..., max_length=3)
    SportpassID:     int                                  # bigint(20) signed
    StandNr:         int = Field(..., **_SM6)
    StandNrText:     str = Field(..., max_length=36)
    Disziplin:       str = Field(..., max_length=36)
    DisziplinID:     int = Field(..., **_INT11)
    Klasse:          str = Field(..., max_length=36)
    KlassenID:       int = Field(..., **_INT11)
    Verein:          str = Field(..., max_length=36)
    VereinsID:       int = Field(..., **_INT11)
    Mannschaft:      str = Field(..., max_length=36)
    MannschaftsID:   int = Field(..., **_INT11)
    Rangliste:       str = Field(..., max_length=36)
    RanglistenID:    int = Field(..., **_INT11)
    Gruppe:          str = Field(..., max_length=36)
    GruppenID:       int = Field(..., **_INT11)
    Status:          str = Field(..., max_length=72)
    Trefferzahl:     int = Field(..., **_SM6)
    TotalRing:       int = Field(..., **_INT11)
    TotalRing01:     int = Field(..., **_INT11)
    BesterTeiler01:  int = Field(..., **_INT11)
    Zeitstempel:     str


class SerieIn(BaseModel):
    ScheibenID: int = Field(..., **_INT11)
    Stellung:   int = Field(..., **_SM6)
    Serie:      int = Field(..., **_SM6)
    Ring:       int = Field(..., **_INT11)
    Ring01:     int = Field(..., **_INT11)


class TrefferIn(BaseModel):
    ScheibenID:    int = Field(..., **_INT11)
    Stellung:      int = Field(..., **_SM6)
    Treffer:       int = Field(..., **_SM6)
    Serie:         int = Field(..., **_SM6)
    x:             int = Field(..., **_INT11)
    y:             int = Field(..., **_INT11)
    Innenzehner:   int = Field(..., ge=0, le=1)           # tinyint(1)
    Wertung:       int = Field(..., **_INT11)
    Text:          str = Field("", max_length=36)
    Ring:          int = Field(..., **_INT11)
    Ring01:        int = Field(..., **_INT11)
    Teiler01:      int = Field(..., **_INT11)
    Teiler001:     int = Field(..., **_INT11)
    Zeitstempel:   str
    Millisekunden: int = Field(..., **_SM6)


class SyncEntry(BaseModel):
    scheibe: ScheibeIn
    serien:  List[SerieIn] = []
    treffer: List[TrefferIn] = []


class SyncPayload(BaseModel):
    scheiben:  List[SyncEntry]
    full_sync: bool = False


class UserAddBody(BaseModel):
    login:        str = Field(..., min_length=1, max_length=50)
    password:     str = Field(..., min_length=10, max_length=100)
    role:         Literal["user", "restricted", "singe"] = "user"
    nachname:     str = Field("", max_length=50)
    vorname:      str = Field("", max_length=50)
    sportpass_id: int = Field(0, ge=0)

    @validator("login")
    def login_chars(cls, v):
        if not _SAFE_LOGIN_RE.match(v):
            raise ValueError("Login darf nur Buchstaben, Ziffern, '_', '.' und '-' enthalten")
        return v

    @validator("password")
    def password_policy(cls, v):
        if not PASSWORD_PATTERN.match(v):
            raise ValueError("Passwort muss mindestens 10 Zeichen lang sein und Buchstaben sowie Zahlen enthalten")
        return v

    @validator("nachname", "vorname")
    def name_chars(cls, v):
        if v and not _SAFE_TEXT_RE.match(v):
            raise ValueError("Name enthält unerlaubte Sonderzeichen")
        return v

    @root_validator
    def singe_requires_identity(cls, values):
        if values.get("role") == "singe" and not (values.get("nachname") and values.get("vorname")):
            raise ValueError("Singe-Benutzer benötigen Nachname und Vorname")
        return values


class PasswordBody(BaseModel):
    password: str = Field(..., min_length=10, max_length=100)

    @validator("password")
    def password_policy(cls, v):
        if not PASSWORD_PATTERN.match(v):
            raise ValueError("Passwort muss mindestens 10 Zeichen lang sein und Buchstaben sowie Zahlen enthalten")
        return v


class RoleBody(BaseModel):
    role:         Literal["user", "restricted", "singe"]
    nachname:     str = Field("", max_length=50)
    vorname:      str = Field("", max_length=50)
    sportpass_id: int = Field(0, ge=0)

    @validator("nachname", "vorname")
    def name_chars(cls, v):
        if v and not _SAFE_TEXT_RE.match(v):
            raise ValueError("Name enthält unerlaubte Sonderzeichen")
        return v

    @root_validator
    def singe_requires_identity(cls, values):
        if values.get("role") == "singe" and not (values.get("nachname") and values.get("vorname")):
            raise ValueError("Singe-Benutzer benötigen Nachname und Vorname")
        return values


# ---------------------------------------------------------------------------
# Response-Modelle (OpenAPI-Dokumentation)
# ---------------------------------------------------------------------------

class TokenResponse(BaseModel):
    access_token: str
    token_type:   str

class StatusResponse(BaseModel):
    status: str

def _parse_optional_dt(v):
    """Konvertiert MySQL-Zero-Dates ('0000-00-00 ...') zu None."""
    if isinstance(v, str) and (v.startswith("0000") or v == ""):
        return None
    return v


class ScheibeListItem(BaseModel):
    Nachname:     str
    Vorname:      str
    Disziplin:    str
    TotalRing:    int
    Zeitstempel:  Optional[datetime]
    ScheibenID:   int
    Starterliste: str
    SportpassID:  int

    _norm_dt = validator("Zeitstempel", pre=True, always=True, allow_reuse=True)(_parse_optional_dt)

class SerieOut(BaseModel):
    ScheibenID: int
    Stellung:   int
    Serie:      int
    Ring:       int
    Ring01:     int

class TrefferOut(BaseModel):
    ScheibenID:    int
    Stellung:      int
    Treffer:       int
    Serie:         int
    x:             int
    y:             int
    Innenzehner:   int
    Wertung:       int
    Text:          str
    Ring:          int
    Ring01:        int
    Teiler01:      int
    Teiler001:     int
    Zeitstempel:   Optional[datetime]
    Millisekunden: int

    _norm_dt = validator("Zeitstempel", pre=True, always=True, allow_reuse=True)(_parse_optional_dt)

class ScheibeDetail(BaseModel):
    ScheibenID:      int
    Starterliste:    str
    StarterlistenID: int
    Startzeit:       Optional[datetime]
    StartNr:         int
    Nachname:        str
    Vorname:         str
    Geschlecht:      str
    Land:            str
    SportpassID:     int
    StandNr:         int
    StandNrText:     str
    Disziplin:       str
    DisziplinID:     int
    Klasse:          str
    KlassenID:       int
    Verein:          str
    VereinsID:       int
    Mannschaft:      str
    MannschaftsID:   int
    Rangliste:       str
    RanglistenID:    int
    Gruppe:          str
    GruppenID:       int
    Status:          str
    Trefferzahl:     int
    TotalRing:       int
    TotalRing01:     int
    BesterTeiler01:  int
    Zeitstempel:     Optional[datetime]

    _norm_dt = validator("Startzeit", "Zeitstempel", pre=True, always=True, allow_reuse=True)(_parse_optional_dt)

class ScheibeWithDetails(BaseModel):
    scheibe: ScheibeDetail
    serien:  List[SerieOut]
    treffer: List[TrefferOut]

class UserOut(BaseModel):
    Login:       str
    Role:        Literal["admin", "user", "restricted", "singe"]
    Nachname:    str
    Vorname:     str
    SportpassID: int


class DeleteResponse(BaseModel):
    status:  str
    deleted: int


# ---------------------------------------------------------------------------
# Sync-Endpunkte (Source → Target)
# ---------------------------------------------------------------------------

@app.get("/sync/status", include_in_schema=False)
def sync_status(request: Request, conn=Depends(get_db)):
    global _last_connection_time
    _verify_sync_token(request)
    _last_connection_time = datetime.now(timezone.utc)
    _save_state()
    with conn.cursor() as cur:
        cur.execute("SELECT COUNT(*) AS cnt FROM Scheiben")
        count = cur.fetchone()["cnt"]
    return {"token": OWN_TOKEN, "scheiben_count": count}


@app.post("/sync", include_in_schema=False)
def sync_receive(request: Request, body: SyncPayload, conn=Depends(get_db)):
    global _last_sync_time
    _verify_sync_token(request)
    _last_sync_time = datetime.now(timezone.utc)
    _save_state()
    received = 0
    with conn.cursor() as cur:
        for entry in body.scheiben:
            scheibe = entry.scheibe.dict()
            _upsert_scheibe(cur, scheibe)
            cur.execute(
                "DELETE FROM Serien WHERE ScheibenID = %s",
                (scheibe["ScheibenID"],),
            )
            cur.execute(
                "DELETE FROM Treffer WHERE ScheibenID = %s",
                (scheibe["ScheibenID"],),
            )
            for serie in entry.serien:
                _insert_serie(cur, serie.dict())
            for treffer in entry.treffer:
                _insert_treffer(cur, treffer.dict())
            received += 1
        conn.commit()
    logging.info(f"Sync empfangen: {received} Scheiben ({'Vollsync' if body.full_sync else 'inkrementell'})")
    return {"token": OWN_TOKEN, "status": "ok", "received": received}


def _upsert_scheibe(cur, s: dict):
    cur.execute("""
        INSERT INTO Scheiben (
          ScheibenID, Starterliste, StarterlistenID, Startzeit, StartNr,
          Nachname, Vorname, Geschlecht, Land, SportpassID, StandNr, StandNrText,
          Disziplin, DisziplinID, Klasse, KlassenID, Verein, VereinsID,
          Mannschaft, MannschaftsID, Rangliste, RanglistenID, Gruppe, GruppenID,
          Status, Trefferzahl, TotalRing, TotalRing01, BesterTeiler01, Zeitstempel
        ) VALUES (
          %(ScheibenID)s, %(Starterliste)s, %(StarterlistenID)s, %(Startzeit)s, %(StartNr)s,
          %(Nachname)s, %(Vorname)s, %(Geschlecht)s, %(Land)s, %(SportpassID)s,
          %(StandNr)s, %(StandNrText)s, %(Disziplin)s, %(DisziplinID)s, %(Klasse)s,
          %(KlassenID)s, %(Verein)s, %(VereinsID)s, %(Mannschaft)s, %(MannschaftsID)s,
          %(Rangliste)s, %(RanglistenID)s, %(Gruppe)s, %(GruppenID)s, %(Status)s,
          %(Trefferzahl)s, %(TotalRing)s, %(TotalRing01)s, %(BesterTeiler01)s, %(Zeitstempel)s
        )
        ON DUPLICATE KEY UPDATE
          Starterliste=VALUES(Starterliste), StarterlistenID=VALUES(StarterlistenID),
          Startzeit=VALUES(Startzeit), StartNr=VALUES(StartNr),
          Nachname=VALUES(Nachname), Vorname=VALUES(Vorname),
          Geschlecht=VALUES(Geschlecht), Land=VALUES(Land),
          SportpassID=VALUES(SportpassID), StandNr=VALUES(StandNr),
          StandNrText=VALUES(StandNrText), Disziplin=VALUES(Disziplin),
          DisziplinID=VALUES(DisziplinID), Klasse=VALUES(Klasse),
          KlassenID=VALUES(KlassenID), Verein=VALUES(Verein),
          VereinsID=VALUES(VereinsID), Mannschaft=VALUES(Mannschaft),
          MannschaftsID=VALUES(MannschaftsID), Rangliste=VALUES(Rangliste),
          RanglistenID=VALUES(RanglistenID), Gruppe=VALUES(Gruppe),
          GruppenID=VALUES(GruppenID), Status=VALUES(Status),
          Trefferzahl=VALUES(Trefferzahl), TotalRing=VALUES(TotalRing),
          TotalRing01=VALUES(TotalRing01), BesterTeiler01=VALUES(BesterTeiler01),
          Zeitstempel=VALUES(Zeitstempel)
    """, s)


def _insert_serie(cur, s: dict):
    cur.execute("""
        INSERT INTO Serien (ScheibenID, Stellung, Serie, Ring, Ring01)
        VALUES (%(ScheibenID)s, %(Stellung)s, %(Serie)s, %(Ring)s, %(Ring01)s)
    """, s)


def _insert_treffer(cur, t: dict):
    cur.execute("""
        INSERT INTO Treffer (
          ScheibenID, Stellung, Treffer, Serie, x, y, Innenzehner,
          Wertung, Text, Ring, Ring01, Teiler01, Teiler001, Zeitstempel, Millisekunden
        ) VALUES (
          %(ScheibenID)s, %(Stellung)s, %(Treffer)s, %(Serie)s, %(x)s, %(y)s,
          %(Innenzehner)s, %(Wertung)s, %(Text)s, %(Ring)s, %(Ring01)s,
          %(Teiler01)s, %(Teiler001)s, %(Zeitstempel)s, %(Millisekunden)s
        )
    """, t)


@app.get("/admin", include_in_schema=False)
def admin_page():
    return FileResponse(os.path.join(os.path.dirname(__file__), "admin.html"))


# ---------------------------------------------------------------------------
# Öffentliche API
# ---------------------------------------------------------------------------

_SCHEIBEN_COLS = (
    "Nachname, Vorname, Disziplin, TotalRing, Zeitstempel, "
    "ScheibenID, Starterliste, SportpassID"
)


@api_router.post("/token", response_model=TokenResponse,
                 tags=["Authentifizierung"], summary="Login — JWT-Token erhalten")
def login(form: OAuth2PasswordRequestForm = Depends(), conn=Depends(get_db)):
    with conn.cursor() as cur:
        cur.execute(
            "SELECT Password, Role, Nachname, Vorname, SportpassID FROM Users WHERE Login = %s",
            (form.username,),
        )
        row = cur.fetchone()
    if not row or not pwd_context.verify(form.password, row["Password"]):
        raise HTTPException(status_code=401, detail="Ungültige Anmeldedaten")
    extra = {}
    if row["Role"] == "singe":
        extra = {
            "singe_nachname":     row["Nachname"],
            "singe_vorname":      row["Vorname"],
            "singe_sportpass_id": row["SportpassID"],
        }
    token = _create_jwt(form.username, row["Role"], **extra)
    return {"access_token": token, "token_type": "bearer"}


@api_router.get("/scheiben/today", response_model=List[ScheibeListItem],
                tags=["Scheiben"], summary="Alle heutigen Scheiben auflisten")
def list_today_scheiben(login: str = Depends(require_full_access), conn=Depends(get_db)):
    today = datetime.now().date()
    with conn.cursor() as cur:
        cur.execute(
            f"SELECT {_SCHEIBEN_COLS} FROM Scheiben "
            "WHERE DATE(Zeitstempel) = %s ORDER BY Zeitstempel DESC",
            (today,),
        )
        return cur.fetchall()


@api_router.get("/scheiben/sportpass/{sportpass_id}", response_model=List[ScheibeListItem],
                tags=["Scheiben"], summary="Scheiben eines Schützen nach SportpassID")
def list_sportpass_scheiben(
    sportpass_id: int = Path(..., ge=1),
    limit: int = Query(default=20, ge=1, le=1000),
    payload: dict = Depends(require_login),
    conn=Depends(get_db),
):
    if payload.get("role") == "singe":
        if payload.get("singe_sportpass_id", 0) != sportpass_id:
            raise HTTPException(status_code=403, detail="Keine Berechtigung für diese SportpassID")
    with conn.cursor() as cur:
        cur.execute(
            f"SELECT {_SCHEIBEN_COLS} FROM Scheiben "
            "WHERE SportpassID = %s ORDER BY Zeitstempel DESC LIMIT %s",
            (sportpass_id, limit),
        )
        return cur.fetchall()


@api_router.get("/scheiben/shooter", response_model=List[ScheibeListItem],
                tags=["Scheiben"], summary="Scheiben eines Schützen nach Name")
def list_shooter_scheiben(
    nachname: str = Query(..., max_length=36, regex=r"^[a-zA-Z0-9äöüÄÖÜß ._/-]+$"),
    vorname:  str = Query(..., max_length=36, regex=r"^[a-zA-Z0-9äöüÄÖÜß ._/-]+$"),
    limit:    int = Query(default=20, ge=1, le=1000),
    payload: dict = Depends(require_login),
    conn=Depends(get_db),
):
    if payload.get("role") == "singe":
        if (payload.get("singe_nachname", "").lower() != nachname.lower() or
                payload.get("singe_vorname", "").lower() != vorname.lower()):
            raise HTTPException(status_code=403, detail="Keine Berechtigung für diesen Schützen")
    with conn.cursor() as cur:
        cur.execute(
            f"SELECT {_SCHEIBEN_COLS} FROM Scheiben "
            "WHERE Nachname = %s AND Vorname = %s "
            "ORDER BY Zeitstempel DESC LIMIT %s",
            (nachname, vorname, limit),
        )
        return cur.fetchall()


@api_router.get("/scheiben/last-per-stand", response_model=List[ScheibeListItem],
                tags=["Scheiben"], summary="Letzte Scheibe je Schießstand")
def list_last_per_stand(login: str = Depends(require_full_access), conn=Depends(get_db)):
    with conn.cursor() as cur:
        cur.execute(f"""
            SELECT {_SCHEIBEN_COLS}
            FROM Scheiben s1
            WHERE Zeitstempel = (
                SELECT MAX(s2.Zeitstempel)
                FROM Scheiben s2
                WHERE s2.StandNr = s1.StandNr
            )
            ORDER BY StandNr
        """)
        return cur.fetchall()


@api_router.get("/scheibe/{scheiben_id}", response_model=ScheibeWithDetails,
                tags=["Scheiben"], summary="Scheibe mit allen Serien und Treffern")
def get_scheibe(
    scheiben_id: int = Path(..., ge=-2_147_483_648, le=2_147_483_647),
    payload: dict = Depends(require_login),
    conn=Depends(get_db),
):
    with conn.cursor() as cur:
        cur.execute("SELECT * FROM Scheiben WHERE ScheibenID = %s", (scheiben_id,))
        scheibe = cur.fetchone()
        if not scheibe:
            raise HTTPException(status_code=404, detail="Scheibe nicht gefunden")
        if payload.get("role") == "singe":
            uid_sportpass = payload.get("singe_sportpass_id", 0)
            uid_nachname = payload.get("singe_nachname", "").lower()
            uid_vorname = payload.get("singe_vorname", "").lower()
            authorized = False
            if uid_sportpass and scheibe["SportpassID"] == uid_sportpass:
                authorized = True
            elif uid_nachname and uid_vorname:
                if (scheibe["Nachname"].lower() == uid_nachname and
                        scheibe["Vorname"].lower() == uid_vorname):
                    authorized = True
            if not authorized:
                raise HTTPException(status_code=403, detail="Keine Berechtigung für diese Scheibe")
        cur.execute(
            "SELECT * FROM Serien WHERE ScheibenID = %s ORDER BY Stellung, Serie",
            (scheiben_id,),
        )
        serien = cur.fetchall()
        cur.execute(
            "SELECT * FROM Treffer WHERE ScheibenID = %s ORDER BY Stellung, Treffer",
            (scheiben_id,),
        )
        treffer = cur.fetchall()
    return {"scheibe": scheibe, "serien": serien, "treffer": treffer}


# ---------------------------------------------------------------------------
# Benutzerverwaltung (nur Admin)
# ---------------------------------------------------------------------------

@api_router.get("/users", response_model=List[UserOut],
                tags=["Benutzerverwaltung"], summary="Alle Benutzer auflisten")
def list_users(admin: str = Depends(require_admin), conn=Depends(get_db)):
    with conn.cursor() as cur:
        cur.execute("SELECT Login, Role, Nachname, Vorname, SportpassID FROM Users ORDER BY Login")
        return cur.fetchall()


@api_router.post("/users", response_model=StatusResponse, status_code=201,
                 tags=["Benutzerverwaltung"], summary="Neuen Benutzer anlegen")
def add_user(body: UserAddBody, admin: str = Depends(require_admin), conn=Depends(get_db)):
    with conn.cursor() as cur:
        cur.execute("SELECT Login FROM Users WHERE Login = %s", (body.login,))
        if cur.fetchone():
            raise HTTPException(status_code=409, detail="User existiert bereits")
        cur.execute(
            "INSERT INTO Users (Login, Password, Role, Nachname, Vorname, SportpassID) "
            "VALUES (%s, %s, %s, %s, %s, %s)",
            (body.login, pwd_context.hash(body.password), body.role,
             body.nachname, body.vorname, body.sportpass_id),
        )
        conn.commit()
    logging.info(f"User '{body.login}' angelegt von Admin '{admin}'")
    return {"status": "ok", "login": body.login}


@api_router.delete("/users/{login}", response_model=StatusResponse,
                   tags=["Benutzerverwaltung"], summary="Benutzer löschen")
def delete_user(
    login: str = Path(..., max_length=50, regex=r"^[a-zA-Z0-9_.-]+$"),
    admin: str = Depends(require_admin),
    conn=Depends(get_db),
):
    if login.lower() == "admin":
        raise HTTPException(status_code=403, detail="Admin-User kann nicht gelöscht werden")
    with conn.cursor() as cur:
        cur.execute("DELETE FROM Users WHERE Login = %s", (login,))
        if cur.rowcount == 0:
            raise HTTPException(status_code=404, detail="User nicht gefunden")
        conn.commit()
    logging.info(f"User '{login}' gelöscht von Admin '{admin}'")
    return {"status": "ok"}


@api_router.put("/users/{login}/password", response_model=StatusResponse,
                tags=["Benutzerverwaltung"], summary="Passwort eines Benutzers ändern")
def change_user_password(
    login: str = Path(..., max_length=50, regex=r"^[a-zA-Z0-9_.-]+$"),
    body: PasswordBody = Body(...),
    admin: str = Depends(require_admin),
    conn=Depends(get_db),
):
    with conn.cursor() as cur:
        cur.execute("UPDATE Users SET Password = %s WHERE Login = %s", (pwd_context.hash(body.password), login))
        if cur.rowcount == 0:
            raise HTTPException(status_code=404, detail="User nicht gefunden")
        conn.commit()
    return {"status": "ok"}


@api_router.put("/users/{login}/role", response_model=StatusResponse,
                tags=["Benutzerverwaltung"], summary="Rolle eines Benutzers ändern")
def change_user_role(
    login: str = Path(..., max_length=50, regex=r"^[a-zA-Z0-9_.-]+$"),
    body: RoleBody = Body(...),
    admin: str = Depends(require_admin),
    conn=Depends(get_db),
):
    if login.lower() == "admin":
        raise HTTPException(status_code=403, detail="Rolle des Admin-Users kann nicht geändert werden")
    with conn.cursor() as cur:
        cur.execute(
            "UPDATE Users SET Role = %s, Nachname = %s, Vorname = %s, SportpassID = %s "
            "WHERE Login = %s",
            (body.role, body.nachname, body.vorname, body.sportpass_id, login),
        )
        if cur.rowcount == 0:
            raise HTTPException(status_code=404, detail="User nicht gefunden")
        conn.commit()
    logging.info("Rolle von '%s' auf '%s' geändert von Admin '%s'", login, body.role, admin)
    return {"status": "ok"}


@api_router.put("/admin/password", response_model=StatusResponse,
                tags=["Benutzerverwaltung"], summary="Admin-Passwort ändern")
def reset_admin_password(body: PasswordBody, admin: str = Depends(require_admin), conn=Depends(get_db)):
    with conn.cursor() as cur:
        cur.execute(
            "UPDATE Users SET Password = %s WHERE Login = 'admin'",
            (pwd_context.hash(body.password),),
        )
        conn.commit()
    logging.info("Admin-Passwort geändert von '%s'", admin)
    return {"status": "ok"}


# ---------------------------------------------------------------------------
# Datenbank-Operationen (nur Admin)
# ---------------------------------------------------------------------------

@api_router.get("/db/disziplinen", response_model=List[str],
                tags=["Datenbank"], summary="Alle vorhandenen Disziplinen auflisten")
def list_disziplinen(admin: str = Depends(require_admin), conn=Depends(get_db)):
    with conn.cursor() as cur:
        cur.execute("SELECT DISTINCT Disziplin FROM Scheiben ORDER BY Disziplin")
        rows = cur.fetchall()
    return [r["Disziplin"] for r in rows]


@api_router.get("/db/starterlisten", response_model=List[str],
                tags=["Datenbank"], summary="Alle vorhandenen Starterlisten auflisten")
def list_starterlisten(admin: str = Depends(require_admin), conn=Depends(get_db)):
    with conn.cursor() as cur:
        cur.execute("SELECT DISTINCT Starterliste FROM Scheiben ORDER BY Starterliste")
        rows = cur.fetchall()
    return [r["Starterliste"] for r in rows]


@api_router.delete("/db/clearErgebnisse", response_model=DeleteResponse,
                   tags=["Datenbank"], summary="Alle Scheiben, Serien und Treffer löschen")
def clear_ergebnisse(admin: str = Depends(require_admin), conn=Depends(get_db)):
    with conn.cursor() as cur:
        cur.execute("DELETE FROM Scheiben")
        deleted = cur.rowcount
        conn.commit()
    logging.warning("Alle Ergebnisse gelöscht (%d Scheiben) von Admin '%s'", deleted, admin)
    return {"status": "ok", "deleted": deleted}


@api_router.delete("/db/clearErgebnisseDisziplin", response_model=DeleteResponse,
                   tags=["Datenbank"], summary="Alle Scheiben einer Disziplin löschen")
def clear_ergebnisse_disziplin(
    disziplin: str = Query(..., min_length=1, max_length=36),
    admin: str = Depends(require_admin),
    conn=Depends(get_db),
):
    with conn.cursor() as cur:
        cur.execute("DELETE FROM Scheiben WHERE Disziplin = %s", (disziplin,))
        deleted = cur.rowcount
        conn.commit()
    logging.warning("Ergebnisse Disziplin '%s' gelöscht (%d Scheiben) von Admin '%s'", disziplin, deleted, admin)
    return {"status": "ok", "deleted": deleted}


@api_router.delete("/db/clearErgebnisseStarterliste", response_model=DeleteResponse,
                   tags=["Datenbank"], summary="Alle Scheiben einer Starterliste löschen")
def clear_ergebnisse_starterliste(
    starterliste: str = Query(..., min_length=1, max_length=36),
    admin: str = Depends(require_admin),
    conn=Depends(get_db),
):
    with conn.cursor() as cur:
        cur.execute("DELETE FROM Scheiben WHERE Starterliste = %s", (starterliste,))
        deleted = cur.rowcount
        conn.commit()
    logging.warning("Ergebnisse Starterliste '%s' gelöscht (%d Scheiben) von Admin '%s'", starterliste, deleted, admin)
    return {"status": "ok", "deleted": deleted}


# ---------------------------------------------------------------------------
# Statistik
# ---------------------------------------------------------------------------

class DisziplinCount(BaseModel):
    disziplin: str
    count: int

class StatsResponse(BaseModel):
    scheiben: int
    serien:   int
    treffer:  int
    scheiben_pro_disziplin: List[DisziplinCount]
    letzter_verbindungsaufbau: Optional[str]
    letzte_synchronisierung:   Optional[str]
    letzte_scheibe:            Optional[str]


@api_router.get("/stats", response_model=StatsResponse,
                tags=["Datenbank"], summary="Datenbankstatistik")
def get_stats(admin: str = Depends(require_admin), conn=Depends(get_db)):
    with conn.cursor() as cur:
        cur.execute("SELECT COUNT(*) AS c FROM Scheiben")
        scheiben = cur.fetchone()["c"]
        cur.execute("SELECT COUNT(*) AS c FROM Serien")
        serien = cur.fetchone()["c"]
        cur.execute("SELECT COUNT(*) AS c FROM Treffer")
        treffer = cur.fetchone()["c"]
        cur.execute(
            "SELECT Disziplin, COUNT(*) AS count FROM Scheiben "
            "GROUP BY Disziplin ORDER BY count DESC, Disziplin ASC"
        )
        by_disziplin = cur.fetchall()
        cur.execute("SELECT MAX(Zeitstempel) AS ts FROM Scheiben")
        row = cur.fetchone()
        letzte_scheibe = row["ts"].isoformat() if row and row["ts"] else None
    return {
        "scheiben": scheiben,
        "serien":   serien,
        "treffer":  treffer,
        "scheiben_pro_disziplin": [
            {"disziplin": r["Disziplin"], "count": r["count"]} for r in by_disziplin
        ],
        "letzter_verbindungsaufbau": _last_connection_time.isoformat() if _last_connection_time else None,
        "letzte_synchronisierung":   _last_sync_time.isoformat() if _last_sync_time else None,
        "letzte_scheibe":            letzte_scheibe,
    }


app.include_router(api_router)
