"""Bearer-key auth.

V1: one static API key, generated on first run and persisted into server.toml.
The dependency returns a Principal so the hosted offering can later swap this
for a licenses/activations lookup without touching any route (same header,
same Principal shape, per-principal quotas via UsageSink).
"""
from __future__ import annotations

import hmac
import os
import re
import secrets
import time
from dataclasses import dataclass
from pathlib import Path

from fastapi import HTTPException, Request
from loguru import logger

_KEY_LINE = re.compile(r'^\s*api_key\s*=\s*"(?P<key>[^"]*)"\s*$', re.MULTILINE)

_CONFIG_TEMPLATE = '''# VoxCast Server configuration
# Every value can also be set via environment variables (VOXCAST_SERVER_*).

# Clients authenticate with: Authorization: Bearer <api_key>
api_key = "{api_key}"

# Network. 127.0.0.1 = this machine only; 0.0.0.0 = reachable on the LAN.
host = "{host}"
port = {port}

# Model tier served by this instance: auto | base | small | medium | large-v3-turbo
# "auto" picks the best tier for the detected GPU. Missing models are
# downloaded automatically on first start.
model = "{model}"

# Compute device: auto | cuda | cpu
device = "{device}"
'''


@dataclass(frozen=True)
class Principal:
    """Who is calling. V1 self-host has exactly one principal; the hosted
    offering later maps bearer tokens to accounts with plans/quotas."""
    id: str
    plan: str = "selfhost"
    quota_seconds: int | None = None


def generate_api_key() -> str:
    return "vxc_" + secrets.token_hex(16)


_KEY_ALLOWED = frozenset(
    "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_-")


def sanitize_api_key(key: str) -> str:
    """A caller-supplied key must be safe to embed in server.toml (no quotes,
    newlines or exotic characters) and long enough to matter. Empty on
    rejection, so callers fall back to a generated key."""
    key = (key or "").strip()
    if 8 <= len(key) <= 128 and all(c in _KEY_ALLOWED for c in key):
        return key
    return ""


def init_config_file(config_path: Path, host: str, port: int,
                     api_key: str = "", model: str = "auto") -> bool:
    """Create server.toml with an API key (installer bootstrap).

    The Windows installer runs `VoxCastServer.exe --init-config` WITH admin
    rights so the config (incl. key) exists before the first real start — a
    later console run as a normal user must never need to write into
    Program Files. Returns False (and touches nothing) when the file exists,
    so upgrades keep the operator's key and edits. `api_key` lets the operator
    set their own during setup; blank or invalid falls back to a generated one.
    `model` is the tier chosen in the wizard (validated; unknown -> auto).
    """
    if config_path.exists():
        return False
    from .config import VALID_MODELS
    model = (model or "auto").strip()
    if model not in VALID_MODELS:
        model = "auto"
    config_path.parent.mkdir(parents=True, exist_ok=True)
    key = sanitize_api_key(api_key) or generate_api_key()
    config_path.write_text(
        _CONFIG_TEMPLATE.format(
            api_key=key, host=host, port=port,
            model=model, device="auto",
        ),
        encoding="utf-8",
    )
    if os.name == "posix":
        try:
            os.chmod(config_path, 0o600)
        except OSError:
            pass
    return True


_MODEL_LINE = re.compile(r'^\s*model\s*=\s*"[^"]*"\s*$', re.MULTILINE)


def set_config_model(config_path: Path, model: str) -> str:
    """Rewrite the `model` line in an existing server.toml (the --set-model
    CLI). Returns the applied tier. Raises FileNotFoundError if the config is
    missing and ValueError for an unknown tier."""
    from .config import VALID_MODELS
    model = (model or "").strip()
    if model not in VALID_MODELS:
        raise ValueError(f"unknown model {model!r}; choose one of "
                         f"{', '.join(sorted(VALID_MODELS))}")
    if not config_path.exists():
        raise FileNotFoundError(config_path)
    text = config_path.read_text(encoding="utf-8")

    def _repl(_m):
        return f'model = "{model}"'
    if _MODEL_LINE.search(text):
        text = _MODEL_LINE.sub(_repl, text, count=1)
    else:
        text = text.rstrip("\n") + f'\nmodel = "{model}"\n'
    config_path.write_text(text, encoding="utf-8")
    return model


def ensure_api_key(config, config_path: Path) -> str:
    """Return the configured key; on first run generate one and persist it
    into server.toml (create the file with a commented template if absent)."""
    if config.api_key:
        return config.api_key

    key = generate_api_key()
    # Lambda replacement so re.sub never interprets backslashes/group refs in
    # the value (defensive; our key is pure hex today).
    def _repl(_m):
        return f'api_key = "{key}"'
    try:
        if config_path.exists():
            text = config_path.read_text(encoding="utf-8")
            if _KEY_LINE.search(text):
                text = _KEY_LINE.sub(_repl, text, count=1)
            else:
                text = f'api_key = "{key}"\n' + text
            config_path.write_text(text, encoding="utf-8")
        else:
            config_path.parent.mkdir(parents=True, exist_ok=True)
            config_path.write_text(
                _CONFIG_TEMPLATE.format(
                    api_key=key, host=config.host, port=config.port,
                    model=config.model, device=config.device,
                ),
                encoding="utf-8",
            )
        # The file holds the plaintext key — restrict it to the owner on
        # POSIX. On Windows the file lands in Program Files where BUILTIN\
        # Users has READ access: every local user can see the key. Accepted
        # for v1 (single shared key, trusted-LAN threat model, documented in
        # the README); the alternative (icacls lockdown) would break the
        # "open the key in Notepad" Start-menu flow for non-admins.
        if os.name == "posix":
            try:
                os.chmod(config_path, 0o600)
            except OSError:
                pass
    except OSError as e:
        # Key still works for this run; it just won't survive a restart.
        logger.warning("Could not persist API key to {}: {}", config_path, e)
    return key


class BearerAuth:
    """FastAPI dependency. Constant-time compare; small in-memory penalty
    window against brute force (fail-open, LAN threat model)."""

    def __init__(self, api_key: str) -> None:
        self._key = api_key
        self._failures: dict[str, list[float]] = {}

    @staticmethod
    def _client_ip(request: Request) -> str:
        # Use ONLY request.client.host. uvicorn's ProxyHeadersMiddleware
        # (proxy_headers=True, forwarded_allow_ips=<proxy>) already rewrites
        # client from X-Forwarded-For, but only when the TCP peer is the trusted
        # proxy. Reading the raw X-Real-IP/X-Forwarded-For headers directly here
        # would let a direct LAN client (host=0.0.0.0, no proxy) spoof its IP and
        # bypass the per-IP brute-force lockout — so we never trust them.
        return request.client.host if request.client else "?"

    def _register_failure(self, ip: str) -> bool:
        """Track failures per IP; True = temporarily blocked."""
        now = time.monotonic()
        window = [t for t in self._failures.get(ip, []) if now - t < 60.0]
        window.append(now)
        # Bounded memory: evict stale entries when the table grows large
        # (keep in-progress lockouts instead of wiping everyone).
        if len(self._failures) > 1024:
            self._failures = {
                k: [t for t in v if now - t < 60.0]
                for k, v in self._failures.items()
                if any(now - t < 60.0 for t in v)
            }
        self._failures[ip] = window
        return len(window) > 20

    async def __call__(self, request: Request) -> Principal:
        header = request.headers.get("authorization", "")
        scheme, _, token = header.partition(" ")
        ip = self._client_ip(request)
        if scheme.lower() != "bearer" or not token:
            raise HTTPException(
                status_code=401,
                detail="Missing bearer token",
                headers={"WWW-Authenticate": "Bearer"},
            )
        if not hmac.compare_digest(token.strip(), self._key):
            if self._register_failure(ip):
                raise HTTPException(status_code=429, detail="Too many attempts")
            raise HTTPException(
                status_code=401,
                detail="Invalid API key",
                headers={"WWW-Authenticate": "Bearer"},
            )
        return Principal(id="local")
