"""
Client-portal auth — the swappable gate (see docs/CLIENT_PORTAL.md).

M1-M3 ride on an interim signed "magic URL": an unguessable token in the link
mints a signed session cookie.  Every portal route depends on get_current_client();
M4 replaces the backing (magic-link / accounts) without touching routes/templates.

The cookie carries the ACCESS-TOKEN id (not the client id) and is re-validated
against the DB on every request, so revoking a link (revoked_at) kills its live
sessions on the next request — not just future clicks.

No new dependency: the cookie is signed with stdlib HMAC-SHA256 over a SECRET_KEY.
"""

import os
import hmac
import json
import time
import uuid
import base64
import hashlib
import logging
import secrets

from fastapi import Request, Depends
from sqlalchemy.orm import Session

from backend.database import get_db
from backend.models import Client, ClientAccessToken, Project

logger = logging.getLogger(__name__)

# Signing secret for portal session cookies. MUST be set to a real secret in prod
# (env). The insecure default only exists so dev/test boots without config.
SECRET_KEY = os.getenv("SECRET_KEY", "dev-insecure-change-me")
if SECRET_KEY == "dev-insecure-change-me":
    logger.warning("[PORTAL] SECRET_KEY is the insecure default — set SECRET_KEY in prod.")

COOKIE_NAME = "portal_session"
COOKIE_MAX_AGE = 60 * 60 * 24 * 30  # 30 days
# Set COOKIE_SECURE=true once the portal is served over HTTPS (TLS terminates at
# the Synology reverse proxy). Default false so plain-HTTP dev still works.
COOKIE_SECURE = os.getenv("COOKIE_SECURE", "false").lower() in ("1", "true", "yes")


class PortalAuthError(Exception):
    """Raised by get_current_client when there's no valid portal session.
    Handled centrally in main.py: HTML routes get the access-required page,
    /portal/api/* routes get a 401 JSON."""


# -- token + cookie primitives ----------------------------------------------

def hash_token(raw: str) -> str:
    """sha256 hex of a raw access-token secret (what we store + look up by)."""
    return hashlib.sha256(raw.encode()).hexdigest()


def _sign(body: str) -> str:
    return hmac.new(SECRET_KEY.encode(), body.encode(), hashlib.sha256).hexdigest()


def make_session_cookie(token_id: str) -> str:
    body = base64.urlsafe_b64encode(
        json.dumps({"tid": token_id, "iat": int(time.time())}).encode()
    ).decode()
    return f"{body}.{_sign(body)}"


def _read_session_cookie(value: str):
    """Return the token id from a signed cookie, or None if missing/tampered."""
    try:
        body, sig = value.rsplit(".", 1)
    except (ValueError, AttributeError):
        return None
    if not hmac.compare_digest(sig, _sign(body)):
        return None
    try:
        data = json.loads(base64.urlsafe_b64decode(body.encode()))
        if not isinstance(data, dict):
            return None
        # Server-side expiry: a leaked cookie isn't valid forever (max_age is only a
        # browser hint). iat is set by make_session_cookie.
        iat = data.get("iat")
        if not isinstance(iat, (int, float)) or (time.time() - iat) > COOKIE_MAX_AGE:
            return None
        return data.get("tid")
    except Exception:
        return None


# -- the dependency every portal route uses ---------------------------------

def client_from_cookie(cookie_value, db: Session):
    """Resolve a Client from a raw session-cookie value, or None. Re-validates the
    access token against the DB each call, so a revoked link / disabled client
    drops immediately. Shared by the HTTP dependency and the WebSocket handler
    (which can't use Request-based Depends)."""
    token_id = _read_session_cookie(cookie_value) if cookie_value else None
    if not token_id:
        return None
    tok = db.query(ClientAccessToken).filter_by(id=token_id, revoked_at=None).first()
    if not tok:
        return None
    return db.query(Client).filter_by(id=tok.client_id, active=True).first()


def get_current_client(request: Request, db: Session = Depends(get_db)) -> Client:
    """Resolve the authenticated client, or raise PortalAuthError."""
    client = client_from_cookie(request.cookies.get(COOKIE_NAME), db)
    if client is None:
        raise PortalAuthError()
    return client


# --- Phase-1 per-project password gate -------------------------------------------
# A portal-enabled project gets its OWN dedicated client (slug "portal-<project.id>")
# owning exactly that project. The project is linked to it via project.client_id so
# the existing client-scoped routes (which resolve projects by Project.client_id ==
# client.id) surface exactly this one project for the portal session — per-project
# isolation with no route changes. (Phase 1 repurposes project.client_id for this; a
# real per-client model is the deferred multi-tenant work.)


def portal_client_for_project(project, db) -> Client:
    """Get-or-create the dedicated 1:1 portal client for a project, and link the
    project to it so the client-scoped routes resolve exactly this project."""
    slug = f"portal-{project.id}"
    client = db.query(Client).filter_by(slug=slug).first()
    if client is None:
        client = Client(id=str(uuid.uuid4()),
                        name=(project.client_name or project.name or "Client"),
                        slug=slug, active=True)
        db.add(client)
        db.flush()
    if project.client_id != client.id:
        project.client_id = client.id   # without this, the client owns no projects
        db.flush()
    return client


def mint_portal_session(project, db) -> str:
    """Ensure the project's portal client + an access token exist; return the token
    id to seal into a session cookie. Reuses an existing token to avoid clutter."""
    client = portal_client_for_project(project, db)
    tok = db.query(ClientAccessToken).filter_by(client_id=client.id, revoked_at=None).first()
    if tok is None:
        tok = ClientAccessToken(id=str(uuid.uuid4()), client_id=client.id,
                                token_hash=hash_token(secrets.token_urlsafe(32)),
                                label="portal")
        db.add(tok)
    db.commit()
    return tok.id


def resolve_project_by_link_token(link_token: str, db):
    """Return the portal-enabled Project for a link token, or None."""
    if not link_token:
        return None
    return db.query(Project).filter_by(
        portal_link_token=link_token, portal_enabled=True).first()


# In-memory brute-force lockout, keyed per link_token (the password is shared per
# project, so per-IP granularity buys nothing and an IP term only lets an attacker
# reset the budget by rotating source IPs). Resets on restart; adequate for a
# read-only surface behind the UniFi edge. Single-worker dev; multi-worker would
# need a shared store.
MAX_ATTEMPTS = 5
LOCK_SECONDS = 15 * 60
_failures: dict = {}  # key -> (count, first_failure_epoch)


def is_locked(key: str) -> bool:
    rec = _failures.get(key)
    if not rec:
        return False
    count, first = rec
    if count < MAX_ATTEMPTS:
        return False
    if (time.time() - first) > LOCK_SECONDS:
        _failures.pop(key, None)  # window expired
        return False
    return True


def register_failure(key: str) -> None:
    count, first = _failures.get(key, (0, time.time()))
    _failures[key] = (count + 1, first)


def clear_failures(key: str) -> None:
    _failures.pop(key, None)
