"""/api/auth/... for the server (AV-054 E1), HTTP-free: the request handler
passes the method, path, JSON body, cookie and headers and sends back what
comes out.

* ``GET  /api/auth/me``                 who is signed in (user null if nobody) + CSRF token
* ``POST /api/auth/login``              {email, password} -> session cookie
* ``POST /api/auth/logout``
* ``GET  /api/auth/invitation?token=``  the invited address (to show on the page)
* ``POST /api/auth/accept``             {token, password, consent_version} -> account + session
* ``POST /api/auth/reset``              {token, password}
* ``POST /api/auth/password``           {current_password, new_password}
* ``DELETE /api/auth/account``          {password} -> the account and all its data
* owner only: ``POST /api/auth/invitations`` {email} -> link,
  ``POST /api/auth/reset-links`` {email} -> link, ``GET /api/auth/users``.

The session cookie is ``__Host-lentiqa_session``: HttpOnly, Secure,
SameSite=Lax, Path=/. Every state-changing request outside /api/auth/login,
/accept and /reset needs the session's CSRF token in ``X-CSRF-Token`` and,
when the browser sends one, an ``Origin`` of this host (csrf_ok()). Sign-in
attempts are limited per (real client IP, address) pair (LoginLimiter): a
flood from one place does not lock the account for its owner elsewhere.
"""

from __future__ import annotations

import secrets
import threading
import time
from collections import defaultdict, deque
from typing import Any, Callable
from urllib.parse import quote, urlsplit

from .store import (
    CONSENT_VERSION,
    SESSION_DAYS,
    AccountError,
    AccountStore,
    Session,
    beta_max_users,
)

COOKIE = "__Host-lentiqa_session"
LOGIN_MAX_FAILURES = 5
LOGIN_WINDOW_SECONDS = 15 * 60
PUBLIC_POSTS = {"/api/auth/login", "/api/auth/accept", "/api/auth/reset"}


class LoginLimiter:
    """At most LOGIN_MAX_FAILURES failed sign-ins per key -- the pair
    (real client IP, e-mail address) -- in LOGIN_WINDOW_SECONDS; in memory
    (one server process)."""

    def __init__(self, *, clock=time.monotonic):
        self._failures: dict[str, deque] = defaultdict(deque)
        self._lock = threading.Lock()
        self._clock = clock

    def retry_after(self, *keys: str) -> int:
        now = self._clock()
        with self._lock:
            waits = []
            for key in keys:
                attempts = self._failures[key]
                while attempts and now - attempts[0] > LOGIN_WINDOW_SECONDS:
                    attempts.popleft()
                if len(attempts) >= LOGIN_MAX_FAILURES:
                    waits.append(int(LOGIN_WINDOW_SECONDS - (now - attempts[0])) + 1)
            return max(waits, default=0)

    def failed(self, *keys: str) -> None:
        now = self._clock()
        with self._lock:
            for key in keys:
                self._failures[key].append(now)

    def succeeded(self, key: str) -> None:
        with self._lock:
            self._failures.pop(key, None)


def cookie_value(header: str | None) -> str | None:
    for part in (header or "").split(";"):
        name, _, value = part.strip().partition("=")
        if name == COOKIE and value:
            return value
    return None


def session_cookie(token: str) -> str:
    return (
        f"{COOKIE}={token}; Path=/; Max-Age={SESSION_DAYS * 86400}; HttpOnly; Secure; SameSite=Lax"
    )


def cleared_cookie() -> str:
    return f"{COOKIE}=; Path=/; Max-Age=0; HttpOnly; Secure; SameSite=Lax"


def csrf_ok(session: Session | None, headers: dict[str, str], host: str | None) -> bool:
    """A state-changing request carries the session's CSRF token, and comes
    from this site when the browser says where it comes from."""
    if session is None:
        return False
    sent = headers.get("X-CSRF-Token") or headers.get("x-csrf-token") or ""
    if not secrets.compare_digest(sent, session.csrf_token):
        return False
    return origin_ok(headers, host)


def origin_ok(headers: dict[str, str], host: str | None) -> bool:
    origin = headers.get("Origin") or headers.get("origin")
    if not origin:
        return True  # not a browser cross-site form: SameSite and the token still hold
    return bool(host) and urlsplit(origin).netloc == host


def _error(error: AccountError) -> tuple[int, dict[str, Any], dict[str, str]]:
    return error.status, {"error": {"code": error.code, "message": str(error)}}, {}


def handle(
    method: str,
    path: str,
    query: dict[str, list[str]],
    body: dict[str, Any] | None,
    *,
    store: AccountStore,
    session: Session | None,
    cookie_token: str | None,
    client: str,
    headers: dict[str, str],
    host: str | None,
    limiter: LoginLimiter,
    delete_user_data: Callable[[str], dict[str, Any]],
) -> tuple[int, dict[str, Any], dict[str, str]]:
    body = body or {}
    try:
        if method == "GET" and path == "/api/auth/me":
            return (
                200,
                {
                    "mode": "accounts",
                    "user": None if session is None else session.user.to_dict(),
                    "csrf_token": None if session is None else session.csrf_token,
                    "consent_version": CONSENT_VERSION,
                },
                {},
            )
        if method == "POST" and path in PUBLIC_POSTS and not origin_ok(headers, host):
            raise AccountError(403, "BAD_ORIGIN", "Żądanie spoza tej strony.")
        if method == "POST" and path == "/api/auth/login":
            email = str(body.get("email") or "").strip().lower()
            key = f"{client}|{email}"
            wait = limiter.retry_after(key)
            if wait:
                return (
                    429,
                    {
                        "error": {
                            "code": "TOO_MANY_ATTEMPTS",
                            "message": f"Za dużo nieudanych prób. Spróbuj ponownie za {wait // 60 + 1} min.",
                        }
                    },
                    {"Retry-After": str(wait)},
                )
            user = store.verify_password(email, body.get("password"))
            if user is None:
                limiter.failed(key)
                raise AccountError(401, "BAD_CREDENTIALS", "Nieprawidłowy e-mail albo hasło.")
            limiter.succeeded(key)
            token, started = store.start_session(user.user_id)
            return (
                200,
                {"user": user.to_dict(), "csrf_token": started.csrf_token},
                {"Set-Cookie": session_cookie(token)},
            )
        if method == "GET" and path == "/api/auth/invitation":
            return 200, {"email": store.invitation_email((query.get("token") or [""])[0])}, {}
        if method == "POST" and path == "/api/auth/accept":
            user = store.accept_invitation(
                str(body.get("token") or ""), body.get("password"), body.get("consent_version")
            )
            token, started = store.start_session(user.user_id)
            return (
                201,
                {"user": user.to_dict(), "csrf_token": started.csrf_token},
                {"Set-Cookie": session_cookie(token)},
            )
        if method == "POST" and path == "/api/auth/reset":
            user = store.reset_password(str(body.get("token") or ""), body.get("password"))
            token, started = store.start_session(user.user_id)
            return (
                200,
                {"user": user.to_dict(), "csrf_token": started.csrf_token},
                {"Set-Cookie": session_cookie(token)},
            )
        # Everything below needs a session and, for changes, its CSRF token.
        if session is None:
            raise AccountError(401, "AUTH_REQUIRED", "Zaloguj się.")
        if method != "GET" and not csrf_ok(session, headers, host):
            raise AccountError(403, "CSRF", "Odśwież stronę i spróbuj ponownie.")
        if method == "POST" and path == "/api/auth/logout":
            store.end_session(cookie_token)
            return 200, {"signed_out": True}, {"Set-Cookie": cleared_cookie()}
        if method == "POST" and path == "/api/auth/password":
            # A new password ends every session and token of the user
            # (AccountStore.set_password); this browser gets a fresh session.
            if store.verify_password(session.user.email, body.get("current_password")) is None:
                raise AccountError(401, "BAD_CREDENTIALS", "Nieprawidłowe obecne hasło.")
            store.set_password(session.user.user_id, body.get("new_password"))
            token, started = store.start_session(session.user.user_id)
            return (
                200,
                {"user": session.user.to_dict(), "csrf_token": started.csrf_token},
                {"Set-Cookie": session_cookie(token)},
            )
        if method == "DELETE" and path == "/api/auth/account":
            if session.user.role == "OWNER":
                raise AccountError(
                    403, "OWNER_ACCOUNT", "Konta właściciela nie da się usunąć z poziomu strony."
                )
            if store.verify_password(session.user.email, body.get("password")) is None:
                raise AccountError(401, "BAD_CREDENTIALS", "Nieprawidłowe hasło.")
            removed = delete_user_data(session.user.user_id)
            store.delete_user(session.user.user_id)
            return 200, {"deleted": True, **removed}, {"Set-Cookie": cleared_cookie()}
        if session.user.role != "OWNER":
            raise AccountError(404, "NOT_FOUND", "Nie ma takiej strony.")
        base = f"https://{host}" if host and not host.startswith("127.0.0.1") else f"http://{host}"
        if method == "POST" and path == "/api/auth/invitations":
            token, expires_at = store.invite(
                session.user.user_id, body.get("email"), max_users=beta_max_users()
            )
            return (
                201,
                {
                    "link": f"{base}/aventurro/auth.html?invitation={quote(token)}",
                    "expires_at": expires_at,
                    "single_use": True,
                },
                {},
            )
        if method == "POST" and path == "/api/auth/reset-links":
            token = store.reset_link_token(body.get("email"))
            return 201, {"link": f"{base}/aventurro/auth.html?reset={quote(token)}"}, {}
        if method == "GET" and path == "/api/auth/users":
            return (
                200,
                {
                    "users": [user.to_dict() for user in store.users()],
                    "open_invitations": store.open_invitations(),
                    "max_users": beta_max_users(),
                },
                {},
            )
        raise AccountError(404, "NOT_FOUND", "Nie ma takiej strony.")
    except AccountError as error:
        return _error(error)
