"""Shared state and FastAPI dependency functions for bincio.serve.

All module-level globals live here so routers can import them without
creating circular dependencies through server.py.
The CLI sets these before uvicorn starts.
"""
from __future__ import annotations

import json
import os
import re
import threading
import time
from pathlib import Path

import jwt as _jwt
from fastapi import Cookie, HTTPException, Request, Response

from bincio.edit.ops import VALID_ACTIVITY_ID as _VALID_ACTIVITY_ID
from bincio.serve.db import (
    User,
    get_session,
    open_db,
)
from bincio.shared.images import ALLOWED_IMAGE_TYPES as _ALLOWED_IMAGE_TYPES  # noqa: F401
from bincio.shared.images import MAX_IMAGE_BYTES as _MAX_IMAGE_BYTES  # noqa: F401
from bincio.shared.images import unique_image_name as _unique_image_name  # noqa: F401

# ── Module-level state (set by CLI before uvicorn starts) ─────────────────────

data_dir: Path | None = None
site_dir: Path | None = None
webroot: Path | None = None
strava_client_id: str = ""
strava_client_secret: str = ""
public_url: str = ""
dem_url: str = "https://api.open-elevation.com"
sync_secret: str = ""
jwt_secret: str = ""  # when set, validates JWTs from bincio-auth instead of DB session lookup
auth_api: str = ""    # when set, proxies user-state admin ops to bincio-auth (e.g. http://127.0.0.1:4040)
oidc_issuer: str = "" # when set, validates RS256 id_tokens via bincio-auth JWKS
_db = None
_strava_sync_running = False
_strava_sync_lock = threading.Lock()
_garmin_sync_running = False
_garmin_sync_lock = threading.Lock()

# ── JWKS cache ────────────────────────────────────────────────────────────────

_jwks_public_key: object = None
_jwks_fetched_at: float = 0.0
_jwks_lock = threading.Lock()
_JWKS_TTL = 3600

# ── Constants ─────────────────────────────────────────────────────────────────

_VALID_HANDLE = re.compile(r'^[a-z0-9][a-z0-9_-]{0,29}$')
_SESSION_COOKIE = "bincio_session"
_COOKIE_MAX_AGE = 30 * 86400  # 30 days
_SESSION_DOMAIN = os.environ.get("SESSION_DOMAIN") or None

_STRAVA_CREDS_FILE = "strava_credentials.json"

_login_attempts: dict[str, list[float]] = {}
_register_attempts: dict[str, list[float]] = {}
_RATE_WINDOW = 900   # 15 minutes
_LOGIN_RATE_LIMIT = 10
_REGISTER_RATE_LIMIT = 5

# ── Core helpers ──────────────────────────────────────────────────────────────

def _get_data_dir() -> Path:
    if data_dir is None:
        raise HTTPException(500, "Server not configured")
    return data_dir


def _get_db():
    global _db
    if _db is None:
        _db = open_db(_get_data_dir())
    return _db


def _strava_creds(handle: str) -> tuple[str, str]:
    """Return (client_id, client_secret) for a user.

    Per-user credentials take precedence over the instance-level globals.
    Returns ("", "") when neither is configured.
    """
    creds_path = _get_data_dir() / handle / _STRAVA_CREDS_FILE
    if creds_path.exists():
        try:
            d = json.loads(creds_path.read_text(encoding="utf-8"))
            cid = str(d.get("client_id", "")).strip()
            csec = str(d.get("client_secret", "")).strip()
            if cid and csec:
                return cid, csec
        except (OSError, json.JSONDecodeError, KeyError, ValueError):
            pass
    return strava_client_id, strava_client_secret


def _check_id(activity_id: str) -> str:
    if not _VALID_ACTIVITY_ID.match(activity_id):
        raise HTTPException(400, "Invalid activity ID")
    return activity_id

# ── Rate limiting ─────────────────────────────────────────────────────────────

def _check_rate_limit(
    ip: str,
    store: dict[str, list[float]],
    limit: int,
    msg: str = "Too many attempts. Try again later.",
) -> None:
    now = time.time()
    attempts = [t for t in store.get(ip, []) if now - t < _RATE_WINDOW]
    store[ip] = attempts
    if len(attempts) >= limit:
        raise HTTPException(429, msg)
    attempts.append(now)
    store[ip] = attempts

# ── Auth dependency functions ─────────────────────────────────────────────────

def _get_jwks_public_key() -> object:
    """Fetch and cache the RSA public key from bincio-auth's JWKS endpoint."""
    global _jwks_public_key, _jwks_fetched_at
    now = time.time()
    with _jwks_lock:
        if _jwks_public_key is not None and now - _jwks_fetched_at < _JWKS_TTL:
            return _jwks_public_key
        import base64
        import urllib.request
        from cryptography.hazmat.primitives.asymmetric.rsa import RSAPublicNumbers
        from cryptography.hazmat.backends import default_backend
        url = f"{oidc_issuer.rstrip('/')}/.well-known/jwks.json"
        with urllib.request.urlopen(url, timeout=5) as r:
            jwks = json.loads(r.read())
        k = jwks["keys"][0]

        def _b64i(s: str) -> int:
            s += "=" * (-len(s) % 4)
            return int.from_bytes(base64.urlsafe_b64decode(s), "big")

        pub = RSAPublicNumbers(_b64i(k["e"]), _b64i(k["n"])).public_key(default_backend())
        _jwks_public_key = pub
        _jwks_fetched_at = now
        return pub


def _decode_rs256(token: str) -> User | None:
    """Decode an RS256 id_token from bincio-auth. Returns None on any failure."""
    try:
        pub = _get_jwks_public_key()
        payload = _jwt.decode(
            token, pub, algorithms=["RS256"],
            options={"verify_aud": False},
            issuer=oidc_issuer,
        )
    except Exception:
        return None
    handle = payload.get("sub")
    if not handle:
        return None
    return User(
        handle=handle,
        display_name=payload.get("name") or payload.get("display_name", ""),
        is_admin=bool(payload.get("is_admin", False)),
        wiki_access=bool(payload.get("wiki_access", True)),
        activity_access=bool(payload.get("activity_access", False)),
        suspended=False,
        created_at=0,
    )


def _decode_hs256(token: str) -> User | None:
    """Decode a bincio-auth HS256 JWT and return a User. Returns None on any failure."""
    try:
        payload = _jwt.decode(token, jwt_secret, algorithms=["HS256"])
    except _jwt.PyJWTError:
        return None
    handle = payload.get("sub")
    if not handle:
        return None
    return User(
        handle=handle,
        display_name=payload.get("display_name", ""),
        is_admin=bool(payload.get("is_admin", False)),
        wiki_access=bool(payload.get("wiki_access", True)),
        activity_access=bool(payload.get("activity_access", False)),
        suspended=False,
        created_at=0,
    )


def _decode_token(token: str) -> User | None:
    """Try RS256 first (if oidc_issuer set), then HS256, then DB session."""
    if oidc_issuer:
        user = _decode_rs256(token)
        if user:
            return user
    if jwt_secret:
        return _decode_hs256(token)
    return get_session(_get_db(), token)


def _current_user(bincio_session: str | None = Cookie(default=None)) -> User | None:
    if not bincio_session:
        return None
    return _decode_token(bincio_session)


def _require_user(bincio_session: str | None = Cookie(default=None)) -> User:
    user = _current_user(bincio_session)
    if not user:
        raise HTTPException(401, "Not authenticated")
    return user


def _require_admin(bincio_session: str | None = Cookie(default=None)) -> User:
    user = _require_user(bincio_session)
    if not user.is_admin:
        raise HTTPException(403, "Admin required")
    return user


def _require_auth(
    request: Request,
    bincio_session: str | None = Cookie(default=None),
) -> User:
    """Accept session cookie (web) OR Authorization: Bearer token (mobile)."""
    token = bincio_session
    if not token:
        auth = request.headers.get("Authorization", "")
        if auth.startswith("Bearer "):
            token = auth[7:]
    if not token:
        raise HTTPException(401, "Not authenticated")
    user = _decode_token(token)
    if not user:
        raise HTTPException(401, "Invalid or expired session")
    return user


def _set_session_cookie(response: Response, token: str) -> None:
    kwargs: dict = dict(
        key=_SESSION_COOKIE,
        value=token,
        max_age=_COOKIE_MAX_AGE,
        httponly=True,
        samesite="lax",
        secure=False,
    )
    if _SESSION_DOMAIN:
        kwargs["domain"] = _SESSION_DOMAIN
    response.set_cookie(**kwargs)
