# HTTP API that receives weather data via POST and stores in PostgreSQL

import os
import json
import logging
import secrets
from contextlib import asynccontextmanager
from datetime import datetime
from pathlib import Path
from typing import Optional

from dotenv import load_dotenv
from fastapi import FastAPI, HTTPException, Request, Header, Depends, status
from fastapi.exceptions import RequestValidationError
from fastapi.responses import JSONResponse
from pydantic import BaseModel, ConfigDict, field_validator
import psycopg
from psycopg_pool import ConnectionPool
from slowapi import Limiter
from slowapi.errors import RateLimitExceeded
from slowapi.util import get_remote_address
import uvicorn


# --------------------------------------------------------------------------- #
# Konfiguration
# --------------------------------------------------------------------------- #

env_path = Path(__file__).parent.parent / ".env"
load_dotenv(dotenv_path=env_path)

COLLECTOR_PORT = int(os.getenv("COLLECTOR_PORT", 8001))

DB_HOST = os.getenv("DB_HOST", "localhost")
DB_PORT = int(os.getenv("DB_PORT", 5432))
DB_NAME = os.getenv("DB_NAME", "wetterstation")
DB_USER = os.getenv("DB_USER")
DB_PASSWORD = os.getenv("DB_PASSWORD")

# Sicherheit
COLLECTOR_API_KEY = os.getenv("COLLECTOR_API_KEY")
ENVIRONMENT = os.getenv("ENVIRONMENT", "production").lower()
IS_DEV = ENVIRONMENT in ("dev", "development", "local")

# Limits
MAX_BODY_BYTES = int(os.getenv("COLLECTOR_MAX_BODY_BYTES", 16 * 1024))  # 16 KiB
RATE_LIMIT = os.getenv("COLLECTOR_RATE_LIMIT", "30/minute")

# Logging — keine Rohdaten auf INFO mehr
logging.basicConfig(
    level=logging.DEBUG if IS_DEV else logging.INFO,
    format="%(asctime)s - %(levelname)s - %(message)s",
)
logger = logging.getLogger(__name__)


# --------------------------------------------------------------------------- #
# Connection Pool
# --------------------------------------------------------------------------- #

def _build_conninfo() -> str:
    return (
        f"host={DB_HOST} port={DB_PORT} dbname={DB_NAME} "
        f"user={DB_USER} password={DB_PASSWORD}"
    )


pool: Optional[ConnectionPool] = None


# --------------------------------------------------------------------------- #
# Rate-Limiter
# --------------------------------------------------------------------------- #

def _limit_key(request: Request) -> str:
    """Rate-Limit-Key: bei API-Key danach, sonst nach IP.

    Hinter Traefik nutzt slowapi standardmaessig die Peer-IP, was der
    Proxy-IP entspricht. Wenn ein API-Key da ist, bevorzugen wir den.
    """
    api_key = request.headers.get("x-api-key")
    if api_key:
        # nur Praefix einsetzen, damit der volle Key nicht in Logs landet
        return f"key:{api_key[:8]}"
    fwd = request.headers.get("x-forwarded-for")
    if fwd:
        return f"ip:{fwd.split(',')[0].strip()}"
    return f"ip:{get_remote_address(request)}"


limiter = Limiter(key_func=_limit_key, default_limits=[])


# --------------------------------------------------------------------------- #
# Auth-Dependency
# --------------------------------------------------------------------------- #

async def require_api_key(x_api_key: Optional[str] = Header(default=None)) -> None:
    """Prueft den API-Key timing-safe gegen die Konfiguration."""
    if not COLLECTOR_API_KEY:
        # Fail-closed: wenn kein Key konfiguriert ist, ist die API gesperrt.
        logger.error(
            "COLLECTOR_API_KEY ist nicht gesetzt - alle Schreibzugriffe blockiert."
        )
        raise HTTPException(
            status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
            detail="Service not configured",
        )
    if not x_api_key or not secrets.compare_digest(x_api_key, COLLECTOR_API_KEY):
        raise HTTPException(
            status_code=status.HTTP_401_UNAUTHORIZED,
            detail="Invalid or missing API key",
        )


# --------------------------------------------------------------------------- #
# Pydantic Models mit Plausibilitaetspruefung
# --------------------------------------------------------------------------- #

class WeatherDataInput(BaseModel):
    # extra-Felder verwerfen statt akzeptieren -> kein Pollution
    model_config = ConfigDict(extra="ignore")

    # Zeitstempel
    time: Optional[str] = None
    datetime: Optional[str] = None
    dateTime: Optional[int] = None

    # Aussentemperatur
    tempOut: Optional[float] = None       # Celsius (neu)
    temperature: Optional[float] = None   # Celsius
    outTemp: Optional[float] = None       # Fahrenheit (alt)

    # Innentemperatur
    tempIn: Optional[float] = None        # Celsius

    # Aussenfeuchte
    humOut: Optional[int] = None
    humidity: Optional[int] = None
    outHumidity: Optional[float] = None

    # Innenfeuchte
    humIn: Optional[int] = None

    # Luftdruck
    pressure: Optional[float] = None      # hPa
    barometer: Optional[float] = None     # inHg
    barTrend: Optional[int] = None

    # Wind
    windAvg: Optional[float] = None
    windSpeed: Optional[float] = None
    wind_speed: Optional[float] = None
    windGust: Optional[float] = None
    wind_gust: Optional[float] = None
    windDir: Optional[float] = None
    wind_dir: Optional[float] = None

    # Niederschlag
    rain: Optional[float] = None
    rainRate: Optional[float] = None
    rain_rate: Optional[float] = None

    # Vorhersage
    forecast: Optional[int] = None

    # Datenquelle
    source: Optional[str] = None

    # ---- Validatoren -----------------------------------------------------

    @field_validator("tempOut", "temperature", "tempIn")
    @classmethod
    def _temp_celsius_range(cls, v: Optional[float]) -> Optional[float]:
        if v is not None and not (-90.0 <= v <= 70.0):
            raise ValueError("temperature out of plausible range (Celsius)")
        return v

    @field_validator("outTemp")
    @classmethod
    def _temp_fahrenheit_range(cls, v: Optional[float]) -> Optional[float]:
        if v is not None and not (-130.0 <= v <= 160.0):
            raise ValueError("outTemp out of plausible range (Fahrenheit)")
        return v

    @field_validator("humOut", "humidity", "humIn")
    @classmethod
    def _humidity_int_range(cls, v: Optional[int]) -> Optional[int]:
        if v is not None and not (0 <= v <= 100):
            raise ValueError("humidity out of range")
        return v

    @field_validator("outHumidity")
    @classmethod
    def _humidity_float_range(cls, v: Optional[float]) -> Optional[float]:
        if v is not None and not (0.0 <= v <= 100.0):
            raise ValueError("outHumidity out of range")
        return v

    @field_validator("pressure")
    @classmethod
    def _pressure_hpa_range(cls, v: Optional[float]) -> Optional[float]:
        if v is not None and not (800.0 <= v <= 1100.0):
            raise ValueError("pressure (hPa) out of plausible range")
        return v

    @field_validator("barometer")
    @classmethod
    def _pressure_inhg_range(cls, v: Optional[float]) -> Optional[float]:
        if v is not None and not (23.0 <= v <= 32.5):
            raise ValueError("barometer (inHg) out of plausible range")
        return v

    @field_validator("windAvg", "windSpeed", "wind_speed", "windGust", "wind_gust")
    @classmethod
    def _wind_speed_range(cls, v: Optional[float]) -> Optional[float]:
        if v is not None and not (0.0 <= v <= 120.0):
            raise ValueError("wind speed out of plausible range")
        return v

    @field_validator("windDir", "wind_dir")
    @classmethod
    def _wind_dir_range(cls, v: Optional[float]) -> Optional[float]:
        if v is not None and not (0.0 <= v <= 360.0):
            raise ValueError("wind_dir out of range")
        return v

    @field_validator("rain", "rainRate", "rain_rate")
    @classmethod
    def _rain_range(cls, v: Optional[float]) -> Optional[float]:
        if v is not None and not (0.0 <= v <= 1000.0):
            raise ValueError("rain value out of plausible range")
        return v

    @field_validator("source")
    @classmethod
    def _source_valid(cls, v: Optional[str]) -> Optional[str]:
        if v is not None and v not in ("loop", "archive"):
            raise ValueError("source must be 'loop' or 'archive'")
        return v

    # ---- Konvertierungen -------------------------------------------------

    def get_datetime_string(self) -> str:
        if self.time:
            return self.time
        if self.datetime:
            return self.datetime
        if self.dateTime is not None:
            # Plausibilitaet: 2000-01-01 .. 2100-01-01
            if not (946684800 <= self.dateTime <= 4102444800):
                raise ValueError("dateTime timestamp out of plausible range")
            return datetime.fromtimestamp(self.dateTime).strftime("%Y-%m-%d %H:%M:%S")
        raise ValueError("Kein Zeitstempel vorhanden (time, datetime oder dateTime)")

    def get_temperature_celsius(self) -> Optional[float]:
        if self.tempOut is not None:
            return self.tempOut
        if self.temperature is not None:
            return self.temperature
        if self.outTemp is not None:
            return (self.outTemp - 32) * 5 / 9
        return None

    def get_temp_in(self) -> Optional[float]:
        return self.tempIn

    def get_humidity_int(self) -> Optional[int]:
        if self.humOut is not None:
            return int(self.humOut)
        if self.humidity is not None:
            return int(self.humidity)
        if self.outHumidity is not None:
            return int(self.outHumidity)
        return None

    def get_humidity_in(self) -> Optional[int]:
        return int(self.humIn) if self.humIn is not None else None

    def get_pressure_hpa(self) -> Optional[float]:
        if self.pressure is not None:
            return self.pressure
        if self.barometer is not None:
            return self.barometer * 33.8639
        return None

    def get_wind_speed(self) -> Optional[float]:
        if self.windAvg is not None:
            return self.windAvg
        if self.windSpeed is not None:
            return self.windSpeed
        return self.wind_speed

    def get_wind_gust(self) -> Optional[float]:
        return self.windGust if self.windGust is not None else self.wind_gust

    def get_wind_dir(self) -> Optional[float]:
        return self.windDir if self.windDir is not None else self.wind_dir

    def get_rain_rate(self) -> Optional[float]:
        return self.rainRate if self.rainRate is not None else self.rain_rate


# --------------------------------------------------------------------------- #
# Datenbank-Setup
# --------------------------------------------------------------------------- #

def setup_database() -> None:
    """Tabelle, fehlende Spalten und Index anlegen (idempotent)."""
    assert pool is not None
    with pool.connection() as conn:
        with conn.cursor() as cursor:
            cursor.execute(
                """
                CREATE TABLE IF NOT EXISTS weather_data (
                    id SERIAL PRIMARY KEY,
                    datetime TIMESTAMPTZ NOT NULL,
                    temperature FLOAT,
                    humidity INTEGER,
                    pressure FLOAT,
                    wind_speed FLOAT,
                    wind_gust FLOAT,
                    wind_dir FLOAT,
                    rain FLOAT,
                    rain_rate FLOAT,
                    received_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
                    UNIQUE(datetime)
                )
                """
            )
            cursor.execute(
                "ALTER TABLE weather_data ADD COLUMN IF NOT EXISTS temp_in FLOAT"
            )
            cursor.execute(
                "ALTER TABLE weather_data ADD COLUMN IF NOT EXISTS humidity_in INTEGER"
            )
            cursor.execute(
                "ALTER TABLE weather_data ADD COLUMN IF NOT EXISTS forecast INTEGER"
            )
            cursor.execute(
                "ALTER TABLE weather_data ADD COLUMN IF NOT EXISTS bar_trend INTEGER"
            )
            cursor.execute(
                "ALTER TABLE weather_data ADD COLUMN IF NOT EXISTS source VARCHAR"
            )
            cursor.execute(
                "CREATE INDEX IF NOT EXISTS idx_weather_datetime_desc "
                "ON weather_data (datetime DESC)"
            )
        conn.commit()
    logger.info("Tabelle weather_data und Index bereit")


# --------------------------------------------------------------------------- #
# FastAPI Lifespan
# --------------------------------------------------------------------------- #

@asynccontextmanager
async def lifespan(app: FastAPI):
    global pool

    # Pflicht-Variablen pruefen — fail fast
    missing = [v for v in ("DB_USER", "DB_PASSWORD") if not os.getenv(v)]
    if missing:
        raise RuntimeError(
            f"Fehlende Umgebungsvariablen: {', '.join(missing)}"
        )
    if not COLLECTOR_API_KEY:
        raise RuntimeError(
            "COLLECTOR_API_KEY ist nicht gesetzt. "
            "Mindestens 32 Zeichen empfohlen (z.B. via 'openssl rand -hex 32')."
        )
    if len(COLLECTOR_API_KEY) < 16:
        raise RuntimeError(
            "COLLECTOR_API_KEY ist zu kurz (Minimum 16 Zeichen)."
        )

    pool = ConnectionPool(
        conninfo=_build_conninfo(),
        min_size=1,
        max_size=5,
        timeout=10,
        kwargs={"autocommit": False},
    )
    pool.wait()
    logger.info("Connection Pool initialisiert (min=1, max=5)")

    setup_database()
    logger.info("Collector laeuft auf Port %d (env=%s)", COLLECTOR_PORT, ENVIRONMENT)

    try:
        yield
    finally:
        if pool is not None:
            pool.close()
            logger.info("Connection Pool geschlossen")


# --------------------------------------------------------------------------- #
# FastAPI App
# --------------------------------------------------------------------------- #

app = FastAPI(
    title="Weather Data Collector API",
    docs_url="/docs" if IS_DEV else None,
    redoc_url=None,
    openapi_url="/openapi.json" if IS_DEV else None,
    lifespan=lifespan,
)

# Rate-Limiter an die App binden
app.state.limiter = limiter


@app.exception_handler(RateLimitExceeded)
async def _rate_limit_handler(request: Request, exc: RateLimitExceeded):
    return JSONResponse(
        status_code=status.HTTP_429_TOO_MANY_REQUESTS,
        content={"detail": "Too many requests"},
    )


@app.exception_handler(RequestValidationError)
async def _validation_handler(request: Request, exc: RequestValidationError):
    # Details ins Log, generische Antwort an den Client.
    logger.warning("Validation error on %s: %s", request.url.path, exc.errors())
    return JSONResponse(
        status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
        content={"detail": "Validation error"},
    )


@app.exception_handler(Exception)
async def _unhandled_handler(request: Request, exc: Exception):
    # NIE Stacktraces oder str(exc) an den Client zurueckgeben.
    logger.exception("Unhandled error on %s", request.url.path)
    return JSONResponse(
        status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
        content={"detail": "Internal server error"},
    )


# --------------------------------------------------------------------------- #
# Body-Size-Middleware
# --------------------------------------------------------------------------- #

@app.middleware("http")
async def _limit_body_size(request: Request, call_next):
    if request.method in ("POST", "PUT", "PATCH"):
        cl = request.headers.get("content-length")
        if cl is not None:
            try:
                if int(cl) > MAX_BODY_BYTES:
                    return JSONResponse(
                        status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE,
                        content={"detail": "Payload too large"},
                    )
            except ValueError:
                return JSONResponse(
                    status_code=status.HTTP_400_BAD_REQUEST,
                    content={"detail": "Invalid Content-Length"},
                )
    return await call_next(request)


# --------------------------------------------------------------------------- #
# Endpoints
# --------------------------------------------------------------------------- #

@app.get("/")
async def root():
    """Info-Endpunkt (kein Auth noetig)."""
    return {
        "service": "Weather Data Collector",
        "version": "2.0.0",
        "endpoint": "POST /weather (X-API-Key required)",
    }


@app.get("/health")
async def health_check():
    """Health-Check ohne Auth, aber ohne sensitive Details."""
    try:
        assert pool is not None
        with pool.connection() as conn:
            with conn.cursor() as cursor:
                cursor.execute("SELECT 1")
        return {"status": "healthy"}
    except Exception:
        logger.exception("Health-Check fehlgeschlagen")
        raise HTTPException(
            status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
            detail="Service unavailable",
        )


def _store_weather(data: WeatherDataInput) -> dict:
    """Schreibt einen Datenpunkt; setzt voraus, dass `data` validiert ist."""
    assert pool is not None

    dt_string = data.get_datetime_string()
    values = (
        dt_string,
        data.get_temperature_celsius(),
        data.get_temp_in(),
        data.get_humidity_int(),
        data.get_humidity_in(),
        data.get_pressure_hpa(),
        data.barTrend,
        data.get_wind_speed(),
        data.get_wind_gust(),
        data.get_wind_dir(),
        data.rain,
        data.get_rain_rate(),
        data.forecast,
        data.source,
    )

    with pool.connection() as conn:
        with conn.cursor() as cursor:
            cursor.execute(
                """
                INSERT INTO weather_data
                    (datetime, temperature, temp_in, humidity, humidity_in,
                     pressure, bar_trend, wind_speed, wind_gust, wind_dir,
                     rain, rain_rate, forecast, source)
                VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
                ON CONFLICT (datetime) DO UPDATE SET
                    temperature = EXCLUDED.temperature,
                    temp_in     = EXCLUDED.temp_in,
                    humidity    = EXCLUDED.humidity,
                    humidity_in = EXCLUDED.humidity_in,
                    pressure    = EXCLUDED.pressure,
                    bar_trend   = EXCLUDED.bar_trend,
                    wind_speed  = EXCLUDED.wind_speed,
                    wind_gust   = EXCLUDED.wind_gust,
                    wind_dir    = EXCLUDED.wind_dir,
                    rain        = EXCLUDED.rain,
                    rain_rate   = EXCLUDED.rain_rate,
                    forecast    = EXCLUDED.forecast,
                    source      = EXCLUDED.source
                """,
                values,
            )
        conn.commit()

    logger.info("Datenpunkt gespeichert fuer %s", dt_string)
    return {"status": "success", "datetime": dt_string}


@app.post("/weather", dependencies=[Depends(require_api_key)])
@limiter.limit(RATE_LIMIT)
async def receive_weather_data(request: Request, data: WeatherDataInput):
    """Wetterdaten empfangen und speichern (Auth + Rate-Limit)."""
    try:
        return _store_weather(data)
    except ValueError as e:
        # Konvertierungs-Fehler (z.B. fehlender Zeitstempel)
        logger.warning("Bad request on /weather: %s", e)
        raise HTTPException(
            status_code=status.HTTP_400_BAD_REQUEST,
            detail="Invalid input",
        )
    except psycopg.Error:
        logger.exception("DB-Fehler beim Speichern")
        raise HTTPException(
            status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
            detail="Storage unavailable",
        )


@app.post("/", dependencies=[Depends(require_api_key)])
@limiter.limit(RATE_LIMIT)
async def root_post(request: Request):
    """Alias fuer POST /weather (Auth + Rate-Limit)."""
    body = await request.body()
    if len(body) > MAX_BODY_BYTES:
        raise HTTPException(
            status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE,
            detail="Payload too large",
        )
    try:
        data_dict = json.loads(body.decode("utf-8"))
    except (UnicodeDecodeError, json.JSONDecodeError):
        raise HTTPException(
            status_code=status.HTTP_400_BAD_REQUEST,
            detail="Invalid JSON",
        )

    try:
        data = WeatherDataInput(**data_dict)
    except Exception:
        # Pydantic-Fehler enthalten ggf. Werte aus dem Body — nicht durchreichen.
        logger.warning("Validation failed on POST /")
        raise HTTPException(
            status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
            detail="Validation error",
        )

    try:
        return _store_weather(data)
    except ValueError:
        raise HTTPException(
            status_code=status.HTTP_400_BAD_REQUEST,
            detail="Invalid input",
        )
    except psycopg.Error:
        logger.exception("DB-Fehler beim Speichern")
        raise HTTPException(
            status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
            detail="Storage unavailable",
        )


# Debug-Endpunkt nur in DEV-Modus + nur mit API-Key
if IS_DEV:
    @app.post("/debug", dependencies=[Depends(require_api_key)])
    async def debug_post(request: Request):
        body = await request.body()
        if len(body) > MAX_BODY_BYTES:
            raise HTTPException(
                status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE,
                detail="Payload too large",
            )
        try:
            payload = json.loads(body.decode("utf-8"))
        except Exception:
            raise HTTPException(status_code=400, detail="Invalid JSON")
        logger.debug("Debug payload: %s", payload)
        return {"status": "logged"}


# --------------------------------------------------------------------------- #
# Entry-Point
# --------------------------------------------------------------------------- #

def main() -> None:
    uvicorn.run(
        app,
        host="0.0.0.0",
        port=COLLECTOR_PORT,
        # In Produktion liegt Traefik davor und terminiert TLS.
        # X-Forwarded-* nur dann auswerten, wenn man dem Proxy vertraut.
        proxy_headers=True,
        forwarded_allow_ips="*",
    )


if __name__ == "__main__":
    main()