"""
OAuth-Manager fuer ARIA. Generischer OAuth2 Authorization-Code-Flow fuer
Spotify, Google, GitHub, Strava, Microsoft etc.

Architektur:
  - Brain haelt einen Pending-Store: state-String → pending Auth-Request
    (mit timeout). Wenn ein Callback ankommt (via aria-bridge ueber RVS),
    matched der state und der code wird gegen access_token getauscht.
  - Token-Storage: /shared/config/oauth_tokens.json (pro Service ein Eintrag
    mit access_token, refresh_token, expires_at, scope).
  - Provider-Configs: /shared/config/oauth_apps.json — pro Service
    {client_id, client_secret, auth_url, token_url, scopes, ...}. Wird
    typischerweise via Diagnostic-UI gefuellt.
  - Token-Refresh: automatisch wenn access_token abgelaufen oder < 60s
    bis Ablauf bei get_token() Aufruf.

OAuth-Callback-URL: https://{RVS_HOST}:{RVS_PORT_PUBLIC}/oauth/callback/{service}
RVS_PORT_PUBLIC ist nicht zwingend gleich RVS_PORT (port-mapping via TLS-Proxy).
ARIA setzt die URL beim Auth-Request automatisch — Stefan muss sie EINMAL pro
Service im Provider-Dashboard registrieren.
"""

from __future__ import annotations

import base64
import json
import logging
import os
import secrets
import time
import urllib.parse
import urllib.request
from pathlib import Path
from typing import Optional

logger = logging.getLogger(__name__)

CONFIG_DIR = Path("/shared/config")
APPS_FILE = CONFIG_DIR / "oauth_apps.json"
TOKENS_FILE = CONFIG_DIR / "oauth_tokens.json"

# Default-Provider-Configs. Werden von oauth_apps.json gemergt (User-Config
# uebersteuert). Aktuell nur Spotify als out-of-the-box Service — fuer alles
# andere benutzt ARIA das `oauth_register_provider` Tool (legt Provider on-
# demand mit den jeweiligen Endpunkten an). Stefan muss bei jedem Provider
# danach nur client_id + client_secret in Diagnostic / App eintragen.
DEFAULT_PROVIDERS: dict[str, dict] = {
    "spotify": {
        "auth_url": "https://accounts.spotify.com/authorize",
        "token_url": "https://accounts.spotify.com/api/token",
        "scopes": ["user-read-playback-state", "user-modify-playback-state",
                   "user-read-currently-playing", "playlist-read-private",
                   "user-library-read"],
        "client_auth": "basic",  # client_id:client_secret als Basic-Auth-Header
    },
}

# Pending Auth-Requests: state → {service, scopes, redirect_uri, created_at}
_PENDING: dict[str, dict] = {}
PENDING_TTL_SEC = 600   # 10 min — laenger nicht sinnvoll, OAuth-Codes sind eh kurzlebig


# ── Helpers ─────────────────────────────────────────────────


def _callback_url(service: str) -> str:
    """Baut die Redirect-URL die wir bei der Provider-Auth angeben.
    Liest RVS_HOST / RVS_PORT_PUBLIC / RVS_TLS aus env."""
    host = os.environ.get("RVS_HOST", "").strip()
    if not host:
        raise RuntimeError("RVS_HOST nicht gesetzt — OAuth-Callbacks nicht moeglich")
    port = os.environ.get("RVS_PORT_PUBLIC", os.environ.get("RVS_PORT", "443")).strip()
    tls = os.environ.get("RVS_TLS", "true").strip().lower() != "false"
    scheme = "https" if tls else "http"
    # Default-Ports 443/80 nicht in URL anhaengen
    if (tls and port == "443") or (not tls and port == "80"):
        return f"{scheme}://{host}/oauth/callback/{service}"
    return f"{scheme}://{host}:{port}/oauth/callback/{service}"


def _load_json(path: Path) -> dict:
    try:
        if path.exists():
            return json.loads(path.read_text(encoding="utf-8"))
    except Exception as exc:
        logger.warning("OAuth-Datei %s lesen fehlgeschlagen: %s", path, exc)
    return {}


def _save_json(path: Path, data: dict) -> None:
    try:
        path.parent.mkdir(parents=True, exist_ok=True)
        tmp = path.with_suffix(path.suffix + ".tmp")
        tmp.write_text(json.dumps(data, indent=2, ensure_ascii=False), encoding="utf-8")
        tmp.replace(path)
        # 600 — enthaelt Secrets
        try: os.chmod(path, 0o600)
        except OSError: pass
    except Exception as exc:
        logger.error("OAuth-Datei %s speichern fehlgeschlagen: %s", path, exc)


def _provider_config(service: str) -> dict:
    """Mergt Default-Provider-Config mit User-Override aus oauth_apps.json."""
    defaults = DEFAULT_PROVIDERS.get(service, {}).copy()
    apps = _load_json(APPS_FILE)
    user = (apps.get(service) or {}).copy()
    # Tiefes Merge nicht noetig — die kollidierenden Felder sind alle scalar/list.
    merged = {**defaults, **user}
    return merged


def _provider_credentials(service: str) -> tuple[str, str]:
    """Liest client_id + client_secret aus oauth_apps.json. Wirft wenn nicht
    konfiguriert — der OAuth-Flow kann ohne nicht starten."""
    apps = _load_json(APPS_FILE)
    entry = apps.get(service) or {}
    cid = (entry.get("client_id") or "").strip()
    sec = (entry.get("client_secret") or "").strip()
    if not cid or not sec:
        raise RuntimeError(
            f"OAuth-App '{service}' nicht konfiguriert. Bitte in Diagnostic > "
            f"OAuth-Apps client_id + client_secret eintragen."
        )
    return cid, sec


def register_provider(service: str, auth_url: str, token_url: str,
                      scopes: Optional[list[str]] = None,
                      client_auth: str = "body",
                      extra_auth_params: Optional[dict] = None,
                      accept_header: Optional[str] = None) -> dict:
    """Schreibt einen neuen Provider-Eintrag in oauth_apps.json. KEINE
    Credentials hier — die bleiben Stefans Job (Diagnostic / App-UI). Wird
    vom Brain-Tool `oauth_register_provider` gerufen.

    Wenn der Service schon existiert: URLs/Scopes werden ueberschrieben,
    aber vorhandene client_id/client_secret bleiben unberuehrt.
    """
    svc = (service or "").strip()
    if not svc or not all(c.isalnum() or c in "_-" for c in svc) or len(svc) > 60:
        raise ValueError(f"Ungueltiger service-Name: {service!r}")
    if not auth_url.startswith(("http://", "https://")):
        raise ValueError(f"auth_url muss http(s):// sein: {auth_url!r}")
    if not token_url.startswith(("http://", "https://")):
        raise ValueError(f"token_url muss http(s):// sein: {token_url!r}")
    if client_auth not in ("body", "basic"):
        raise ValueError(f"client_auth muss 'body' oder 'basic' sein: {client_auth!r}")

    apps = _load_json(APPS_FILE)
    entry = apps.get(svc) or {}
    entry["auth_url"] = auth_url.strip()
    entry["token_url"] = token_url.strip()
    if scopes is not None:
        entry["scopes"] = list(scopes)
    entry["client_auth"] = client_auth
    if extra_auth_params is not None:
        entry["extra_auth_params"] = extra_auth_params
    if accept_header is not None:
        entry["accept_header"] = accept_header
    apps[svc] = entry
    _save_json(APPS_FILE, apps)
    logger.info("[oauth] Provider '%s' registriert (auth=%s, token=%s, scopes=%d)",
                svc, auth_url, token_url, len(entry.get("scopes") or []))
    return entry


def _cleanup_pending() -> None:
    """Entfernt abgelaufene Pending-Auths."""
    now = time.time()
    for state, info in list(_PENDING.items()):
        if now - info.get("created_at", 0) > PENDING_TTL_SEC:
            _PENDING.pop(state, None)


# ── Authorize ───────────────────────────────────────────────


def build_authorize_url(service: str, scopes: Optional[list[str]] = None,
                        extra_params: Optional[dict] = None) -> dict:
    """Baut die Authorize-URL fuer einen Provider. Speichert den state
    im Pending-Store. Returns {url, state, redirect_uri, service}.

    Wird vom Brain-Tool oauth_authorize gerufen. ARIA gibt die url an Stefan,
    der oeffnet sie im Browser, autorisiert, Provider redirected zur
    redirect_uri (= RVS), RVS broadcasted, bridge forwarded, brain matched
    state → exchange.
    """
    _cleanup_pending()
    cfg = _provider_config(service)
    if not cfg.get("auth_url") or not cfg.get("token_url"):
        raise RuntimeError(f"Provider '{service}' hat keine auth_url/token_url. "
                           f"In oauth_apps.json eintragen oder einen der "
                           f"vordefinierten Services nutzen ({', '.join(DEFAULT_PROVIDERS)}).")
    cid, _ = _provider_credentials(service)
    redirect_uri = _callback_url(service)
    state = secrets.token_urlsafe(32)
    use_scopes = scopes if scopes else cfg.get("scopes") or []

    params = {
        "client_id": cid,
        "response_type": "code",
        "redirect_uri": redirect_uri,
        "state": state,
    }
    if use_scopes:
        params["scope"] = " ".join(use_scopes)
    params.update(cfg.get("extra_auth_params") or {})
    if extra_params:
        params.update(extra_params)

    url = cfg["auth_url"] + "?" + urllib.parse.urlencode(params)

    _PENDING[state] = {
        "service": service,
        "redirect_uri": redirect_uri,
        "scopes": use_scopes,
        "created_at": time.time(),
    }
    logger.info("[oauth] Authorize-URL fuer %s gebaut: state=%s redirect=%s",
                service, state[:8] + "...", redirect_uri)
    return {"url": url, "state": state, "redirect_uri": redirect_uri, "service": service}


# ── Token-Exchange ──────────────────────────────────────────


def _token_request(token_url: str, body_params: dict, cfg: dict,
                   client_id: str, client_secret: str) -> dict:
    """POST an provider /token endpoint. Returns parsed JSON oder wirft."""
    data = urllib.parse.urlencode(body_params).encode("utf-8")
    headers = {"Content-Type": "application/x-www-form-urlencoded"}
    if cfg.get("accept_header"):
        headers["Accept"] = cfg["accept_header"]
    # Client-Auth: 'basic' (Header) oder 'body' (im Form-Body)
    if cfg.get("client_auth") == "basic":
        auth_str = f"{client_id}:{client_secret}"
        b64 = base64.b64encode(auth_str.encode("utf-8")).decode("ascii")
        headers["Authorization"] = f"Basic {b64}"
    else:
        # bereits im body_params drin (siehe Caller)
        pass
    req = urllib.request.Request(token_url, data=data, method="POST", headers=headers)
    try:
        with urllib.request.urlopen(req, timeout=15) as resp:
            raw = resp.read().decode("utf-8", "ignore")
            try:
                return json.loads(raw)
            except json.JSONDecodeError:
                # GitHub default ist form-urlencoded — accept_header sollte
                # JSON anfordern, aber falls's doch mal kommt:
                parsed = urllib.parse.parse_qs(raw)
                return {k: v[0] if isinstance(v, list) and v else v for k, v in parsed.items()}
    except urllib.error.HTTPError as e:
        body = e.read().decode("utf-8", "ignore")[:500]
        raise RuntimeError(f"Token-Request HTTP {e.code}: {body}") from e


def handle_callback(service: str, code: str, state: str,
                    error: Optional[str] = None,
                    error_description: Optional[str] = None) -> dict:
    """Verarbeitet einen OAuth-Callback. Validiert state, tauscht code gegen
    Token, speichert. Returns {ok, service, message, ...}.

    Wird von /internal/oauth-callback (HTTP, von aria-bridge) gerufen.
    """
    _cleanup_pending()

    if error:
        # Provider hat User-Abbruch oder Fehler gemeldet
        _PENDING.pop(state, None) if state else None
        logger.warning("[oauth] Provider-Error %s/%s: %s — %s",
                       service, state[:8] + "..." if state else "?", error, error_description)
        return {"ok": False, "service": service, "error": error,
                "errorDescription": error_description}

    pending = _PENDING.pop(state, None)
    if not pending:
        logger.warning("[oauth] Unknown state %s fuer %s — abgelaufen oder CSRF?", state[:8] + "...", service)
        return {"ok": False, "service": service,
                "error": "invalid_state",
                "errorDescription": "Unbekannter oder abgelaufener state (Auth-Anfrage muss erst per oauth_authorize neu gestartet werden)."}
    if pending.get("service") != service:
        logger.warning("[oauth] state-Service-Mismatch: pending=%s vs callback=%s",
                       pending.get("service"), service)
        return {"ok": False, "service": service,
                "error": "service_mismatch",
                "errorDescription": "state gehoert zu einem anderen Service."}

    if not code:
        return {"ok": False, "service": service, "error": "no_code"}

    cfg = _provider_config(service)
    try:
        client_id, client_secret = _provider_credentials(service)
    except RuntimeError as exc:
        return {"ok": False, "service": service, "error": "no_credentials",
                "errorDescription": str(exc)}

    body = {
        "grant_type": "authorization_code",
        "code": code,
        "redirect_uri": pending["redirect_uri"],
    }
    if cfg.get("client_auth") != "basic":
        body["client_id"] = client_id
        body["client_secret"] = client_secret

    try:
        token_data = _token_request(cfg["token_url"], body, cfg, client_id, client_secret)
    except Exception as exc:
        logger.exception("[oauth] Token-Exchange fehlgeschlagen fuer %s", service)
        return {"ok": False, "service": service, "error": "exchange_failed",
                "errorDescription": str(exc)[:200]}

    access = token_data.get("access_token")
    if not access:
        return {"ok": False, "service": service, "error": "no_access_token",
                "errorDescription": str(token_data)[:200]}

    expires_in = int(token_data.get("expires_in") or 3600)
    refresh = token_data.get("refresh_token") or ""
    scope = token_data.get("scope") or " ".join(pending.get("scopes") or [])
    token_type = token_data.get("token_type") or "Bearer"

    record = {
        "service": service,
        "access_token": access,
        "refresh_token": refresh,
        "token_type": token_type,
        "scope": scope,
        "expires_at": int(time.time()) + expires_in,
        "obtained_at": int(time.time()),
    }
    _persist_token(service, record)
    logger.info("[oauth] %s authentifiziert — expires in %ds, refresh=%s",
                service, expires_in, "ja" if refresh else "nein")
    return {"ok": True, "service": service, "expiresIn": expires_in,
            "hasRefresh": bool(refresh), "scope": scope}


# ── Token-Storage / Refresh / Revoke ─────────────────────────


def _persist_token(service: str, record: dict) -> None:
    tokens = _load_json(TOKENS_FILE)
    tokens[service] = record
    _save_json(TOKENS_FILE, tokens)


def _load_token(service: str) -> Optional[dict]:
    return _load_json(TOKENS_FILE).get(service)


def get_token(service: str, refresh_threshold_sec: int = 60) -> dict:
    """Holt das aktuelle access_token fuer einen Service. Refresht automatisch
    wenn weniger als refresh_threshold_sec Restzeit. Returns das ganze
    record-dict — Caller nimmt sich access_token raus.

    Wirft wenn nicht authentifiziert oder Refresh fehlschlaegt — Tool-Aufrufer
    soll dann oauth_authorize anbieten."""
    record = _load_token(service)
    if not record:
        raise RuntimeError(f"Kein Token fuer '{service}' gespeichert. Erst per "
                           f"oauth_authorize authentifizieren.")
    exp = int(record.get("expires_at") or 0)
    remaining = exp - int(time.time())
    if remaining > refresh_threshold_sec:
        return record
    # Refresh noetig
    refresh_tok = (record.get("refresh_token") or "").strip()
    if not refresh_tok:
        raise RuntimeError(f"Token fuer '{service}' abgelaufen und kein refresh_token "
                           f"vorhanden — bitte neu autorisieren mit oauth_authorize.")
    cfg = _provider_config(service)
    client_id, client_secret = _provider_credentials(service)
    body = {
        "grant_type": "refresh_token",
        "refresh_token": refresh_tok,
    }
    if cfg.get("client_auth") != "basic":
        body["client_id"] = client_id
        body["client_secret"] = client_secret
    try:
        new_data = _token_request(cfg["token_url"], body, cfg, client_id, client_secret)
    except Exception as exc:
        raise RuntimeError(f"Token-Refresh fuer '{service}' fehlgeschlagen: {exc}") from exc

    new_access = new_data.get("access_token")
    if not new_access:
        raise RuntimeError(f"Refresh-Antwort ohne access_token: {new_data}")
    expires_in = int(new_data.get("expires_in") or 3600)
    # refresh_token kann (manche Provider) bei jedem Refresh rotieren
    new_refresh = (new_data.get("refresh_token") or refresh_tok).strip()
    record.update({
        "access_token": new_access,
        "refresh_token": new_refresh,
        "expires_at": int(time.time()) + expires_in,
        "obtained_at": int(time.time()),
    })
    if new_data.get("scope"):
        record["scope"] = new_data["scope"]
    _persist_token(service, record)
    logger.info("[oauth] %s Token refreshed — neue Restzeit %ds", service, expires_in)
    return record


def revoke(service: str) -> bool:
    """Entfernt das Token aus dem Storage (Best-Effort, kein Provider-Revoke-Call)."""
    tokens = _load_json(TOKENS_FILE)
    if service not in tokens:
        return False
    tokens.pop(service, None)
    _save_json(TOKENS_FILE, tokens)
    logger.info("[oauth] %s Token geloescht (lokal).", service)
    return True


def list_services() -> list[dict]:
    """Diagnostik: zeigt fuer jeden konfigurierten Service ob Token da ist
    + Ablaufzeit. Wird von Diagnostic genutzt."""
    apps = _load_json(APPS_FILE)
    tokens = _load_json(TOKENS_FILE)
    out = []
    services = set(apps.keys()) | set(tokens.keys()) | set(DEFAULT_PROVIDERS.keys())
    now = int(time.time())
    for s in sorted(services):
        app = apps.get(s) or {}
        tok = tokens.get(s) or {}
        configured = bool(app.get("client_id") and app.get("client_secret"))
        out.append({
            "service": s,
            "configured": configured,
            "authenticated": bool(tok.get("access_token")),
            "expiresAt": tok.get("expires_at"),
            "expiresInSec": (tok.get("expires_at", 0) - now) if tok.get("expires_at") else None,
            "hasRefresh": bool(tok.get("refresh_token")),
            "scope": tok.get("scope", ""),
            "isDefault": s in DEFAULT_PROVIDERS,
        })
    return out
