from functools import lru_cache
import hashlib
import hmac
from ipaddress import ip_address
import json
import logging
from time import time
import secrets

from fastapi import Depends, HTTPException, Request, Response, status
from fastapi.security import HTTPAuthorizationCredentials, HTTPBasic, HTTPBasicCredentials, HTTPBearer
from passlib.context import CryptContext
from redis.exceptions import RedisError

from .cache import get_cache
from .settings import get_settings

security = HTTPBasic(auto_error=False)
bearer_security = HTTPBearer(auto_error=False)
settings = get_settings()
admin_pwd_context = CryptContext(schemes=["argon2"], default="argon2", argon2__type="ID")

RATE_LIMIT_PREFIX = "rate-limit:"
PANEL_SESSION_PREFIX = "panel-session:"
PANEL_SESSION_COOKIE = "limristem_mail_panel_session"
PANEL_LOGIN_CSRF_COOKIE = "limristem_mail_panel_login_csrf"
PANEL_MFA_PENDING_COOKIE = "limristem_mail_panel_mfa_pending"
PANEL_COOKIE_PATH = "/panel"
PANEL_LOGIN_CSRF_TTL_SECONDS = 600
PANEL_LOGIN_CSRF_CLOCK_SKEW_SECONDS = 5
PANEL_LOGIN_CSRF_FALLBACK_SECRET = secrets.token_hex(32)
logger = logging.getLogger(__name__)


def is_trusted_proxy_address(address: str | None) -> bool:
    if not address:
        return False
    try:
        candidate = ip_address(address)
    except ValueError:
        return False
    return any(candidate in network for network in settings.trusted_proxies)


def forwarded_client_address(forwarded_for: str | None) -> str | None:
    if not forwarded_for:
        return None
    candidates = []
    for raw_candidate in forwarded_for.split(","):
        candidate = raw_candidate.strip()
        if not candidate:
            continue
        try:
            candidates.append(str(ip_address(candidate)))
        except ValueError:
            continue
    for candidate in reversed(candidates):
        if not is_trusted_proxy_address(candidate):
            return candidate
    return None


def client_address(request: Request) -> str:
    direct_address = request.client.host if request.client and request.client.host else None
    if is_trusted_proxy_address(direct_address):
        forwarded_for = forwarded_client_address(request.headers.get("x-forwarded-for"))
        if forwarded_for:
            return forwarded_for
    if direct_address:
        return direct_address
    return "unknown"


def _rate_block_key(address: str) -> str:
    return f"{RATE_LIMIT_PREFIX}block:{address}"


def _rate_failures_key(address: str, window: int) -> str:
    return f"{RATE_LIMIT_PREFIX}failures:{address}:{window}"


def ensure_auth_storage() -> None:
    """Fail closed when Redis (session/rate-limit/token store) is unavailable."""
    try:
        get_cache().ping()
    except RedisError as exc:
        raise HTTPException(
            status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
            detail="Authentication backend unavailable",
        ) from exc


def is_rate_limited(address: str) -> bool:
    try:
        cache = get_cache()
        block_key = _rate_block_key(address)
        if cache.exists(block_key):
            return True
        return False
    except RedisError as exc:
        # Fail closed: treat as limited so attackers cannot bypass lockout.
        logger.warning("Rate-limit backend unavailable for %s: %s", address, exc)
        raise HTTPException(
            status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
            detail="Authentication backend unavailable",
        ) from exc


def register_auth_failure(address: str) -> None:
    try:
        cache = get_cache()
        window = int(time() / settings.api_auth_window_seconds)
        failures_key = _rate_failures_key(address, window)
        fail_count = cache.incr(failures_key)
        cache.expire(failures_key, settings.api_auth_window_seconds * 2)
        if fail_count >= settings.api_auth_fail_limit:
            block_key = _rate_block_key(address)
            cache.setex(block_key, settings.api_auth_block_seconds, "1")
            cache.delete(failures_key)
    except RedisError as exc:
        logger.warning("Unable to register auth failure for %s: %s", address, exc)
        raise HTTPException(
            status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
            detail="Authentication backend unavailable",
        ) from exc


def clear_auth_failures(address: str) -> None:
    try:
        cache = get_cache()
        block_key = _rate_block_key(address)
        cache.delete(block_key)
        # Clean up all recent windows
        for offset in range(2):
            window = int((time() - offset * settings.api_auth_window_seconds) / settings.api_auth_window_seconds)
            cache.delete(_rate_failures_key(address, window))
    except RedisError as exc:
        logger.warning("Unable to clear auth failures for %s: %s", address, exc)


def verify_secret(password: str, password_hash: str | None, label: str) -> bool:
    if not password_hash:
        raise HTTPException(
            status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
            detail=f"{label} credentials are not configured (password hash required)",
        )
    try:
        return admin_pwd_context.verify(password, password_hash)
    except ValueError as exc:
        raise HTTPException(
            status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
            detail=f"{label} auth configuration is invalid",
        ) from exc


def verify_admin_secret(password: str) -> bool:
    return verify_secret(password, settings.api_admin_pass_hash, "Admin")


def verify_panel_secret(password: str) -> bool:
    return verify_secret(
        password,
        settings.panel_admin_pass_hash or settings.api_admin_pass_hash,
        "Panel",
    )


def panel_admin_username() -> str:
    return settings.panel_admin_user or settings.api_admin_user


def panel_auth_fingerprint_material() -> str:
    return (
        settings.panel_admin_pass_hash
        or settings.api_admin_pass_hash
        or panel_login_csrf_key().hex()
    )


def panel_auth_fingerprint() -> str:
    return hashlib.sha256(f"{panel_admin_username()}:{panel_auth_fingerprint_material()}".encode("utf-8")).hexdigest()


def panel_user_agent_hash(request: Request) -> str:
    user_agent = request.headers.get("user-agent", "")
    return hashlib.sha256(user_agent.encode("utf-8")).hexdigest()


def panel_session_key(session_id: str) -> str:
    return f"{PANEL_SESSION_PREFIX}{session_id}"


def panel_login_csrf_scope(request: Request) -> str:
    return f"{client_address(request)}:{panel_user_agent_hash(request)}"


@lru_cache(maxsize=1)
def warn_panel_login_csrf_fallback_once() -> None:
    logger.warning(
        "LIMRISTEM_MAIL_PANEL_LOGIN_CSRF_SECRET is not configured and no admin credential material is available; "
        "a temporary per-process secret is being used instead, the hostname is not included in panel login CSRF "
        "signing, tokens will be invalidated on process restart, and multi-process deployments may see token "
        "validation fail across workers until a stable secret is configured."
    )


def panel_login_csrf_key() -> bytes:
    secret_material = (
        settings.panel_login_csrf_secret
        or settings.panel_admin_pass_hash
        or settings.api_admin_pass_hash
    )
    if not secret_material:
        secret_material = PANEL_LOGIN_CSRF_FALLBACK_SECRET
        warn_panel_login_csrf_fallback_once()
    return hashlib.sha256(secret_material.encode("utf-8")).digest()


def build_panel_login_csrf_token(request: Request) -> str:
    issued_at = str(int(time()))
    nonce = secrets.token_urlsafe(16)
    signature = hmac.new(
        panel_login_csrf_key(),
        f"{issued_at}:{nonce}:{panel_login_csrf_scope(request)}".encode("utf-8"),
        hashlib.sha256,
    ).hexdigest()
    return f"{issued_at}.{nonce}.{signature}"


def issue_panel_login_csrf(response: Response, request: Request, token: str | None = None) -> str:
    token = token or build_panel_login_csrf_token(request)
    response.set_cookie(
        PANEL_LOGIN_CSRF_COOKIE,
        token,
        max_age=PANEL_LOGIN_CSRF_TTL_SECONDS,
        httponly=True,
        secure=(settings.ssl_mode != "plain"),
        samesite="lax",
        path=PANEL_COOKIE_PATH,
    )
    return token


def clear_panel_login_csrf(response: Response) -> None:
    response.delete_cookie(PANEL_LOGIN_CSRF_COOKIE, path=PANEL_COOKIE_PATH)


def validate_panel_login_csrf(request: Request, submitted_token: str) -> None:
    cookie_token = request.cookies.get(PANEL_LOGIN_CSRF_COOKIE)
    if not cookie_token or not secrets.compare_digest(cookie_token, submitted_token):
        raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Invalid CSRF token")

    issued_at, separator, remainder = submitted_token.partition(".")
    nonce, separator2, signature = remainder.partition(".")
    if not issued_at or not separator or not nonce or not separator2 or not signature:
        raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Invalid CSRF token")

    try:
        issued_at_value = int(issued_at)
    except ValueError as exc:
        raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Invalid CSRF token") from exc

    now = int(time())
    if (
        issued_at_value > now + PANEL_LOGIN_CSRF_CLOCK_SKEW_SECONDS
        or now - issued_at_value > PANEL_LOGIN_CSRF_TTL_SECONDS
    ):
        raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Invalid CSRF token")

    expected_signature = hmac.new(
        panel_login_csrf_key(),
        f"{issued_at}:{nonce}:{panel_login_csrf_scope(request)}".encode("utf-8"),
        hashlib.sha256,
    ).hexdigest()
    if not secrets.compare_digest(expected_signature, signature):
        raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Invalid CSRF token")


def issue_panel_session(response: Response, request: Request, username: str) -> dict:
    session_id = secrets.token_urlsafe(32)
    session = {
        "username": username,
        "role": "admin",
        "csrf_token": secrets.token_urlsafe(32),
        "user_agent_hash": panel_user_agent_hash(request),
        "auth_fingerprint": panel_auth_fingerprint(),
    }
    try:
        get_cache().set(panel_session_key(session_id), json.dumps(session), ex=settings.panel_session_ttl_seconds)
    except RedisError as exc:
        raise HTTPException(status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail="Session storage unavailable") from exc
    response.set_cookie(
        PANEL_SESSION_COOKIE,
        session_id,
        max_age=settings.panel_session_ttl_seconds,
        httponly=True,
        secure=(settings.ssl_mode != "plain"),
        samesite="lax",
        path=PANEL_COOKIE_PATH,
    )
    clear_panel_login_csrf(response)
    return session


def load_panel_session(request: Request) -> dict | None:
    session_id = request.cookies.get(PANEL_SESSION_COOKIE)
    if not session_id:
        return None
    try:
        raw_session = get_cache().get(panel_session_key(session_id))
    except RedisError as exc:
        raise HTTPException(status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail="Session storage unavailable") from exc
    if not raw_session:
        return None
    try:
        session = json.loads(raw_session)
    except json.JSONDecodeError:
        session = None
    valid_username = panel_admin_username()
    if (
        not isinstance(session, dict)
        or not isinstance(session.get("username"), str)
        or not session.get("csrf_token")
        or session.get("role") != "admin"
        or not secrets.compare_digest(session["username"], valid_username)
        or session.get("auth_fingerprint") != panel_auth_fingerprint()
        or session.get("user_agent_hash") != panel_user_agent_hash(request)
    ):
        try:
            get_cache().delete(panel_session_key(session_id))
        except RedisError:
            pass
        return None
    try:
        get_cache().expire(panel_session_key(session_id), settings.panel_session_ttl_seconds)
    except RedisError:
        pass
    return session


def _save_panel_session(request: Request, session: dict) -> bool:
    session_id = request.cookies.get(PANEL_SESSION_COOKIE)
    if not session_id:
        return False
    try:
        get_cache().set(
            panel_session_key(session_id),
            json.dumps(session),
            ex=settings.panel_session_ttl_seconds,
        )
    except RedisError as exc:
        raise HTTPException(status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail="Session storage unavailable") from exc
    return True


def set_panel_flash(request: Request, session: dict, key: str, value: str) -> bool:
    flashes = session.get("flashes")
    if not isinstance(flashes, dict):
        flashes = {}
    flashes[key] = value
    session["flashes"] = flashes
    return _save_panel_session(request, session)


def pop_panel_flash(request: Request, session: dict, key: str) -> str:
    flashes = session.get("flashes")
    if not isinstance(flashes, dict):
        return ""
    value = flashes.pop(key, "")
    if flashes:
        session["flashes"] = flashes
    else:
        session.pop("flashes", None)
    _save_panel_session(request, session)
    return value if isinstance(value, str) else ""


def delete_panel_session(response: Response, request: Request) -> None:
    session_id = request.cookies.get(PANEL_SESSION_COOKIE)
    if session_id:
        try:
            get_cache().delete(panel_session_key(session_id))
        except RedisError:
            pass
    response.delete_cookie(PANEL_SESSION_COOKIE, path=PANEL_COOKIE_PATH)
    clear_panel_login_csrf(response)


def require_panel_session(request: Request) -> dict:
    session = load_panel_session(request)
    if not session:
        raise HTTPException(
            status_code=status.HTTP_303_SEE_OTHER,
            detail="Panel authentication required",
            headers={"Location": "/panel/login"},
        )
    return session


def require_panel_csrf(session: dict, submitted_token: str) -> None:
    expected_token = session.get("csrf_token")
    if not expected_token or not secrets.compare_digest(expected_token, submitted_token):
        raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Invalid CSRF token")


def _admin_mfa_required() -> bool:
    """Whether admin MFA is active. Fail closed if the DB is unavailable."""
    try:
        from .db import SessionLocal
        from .mfa import admin_mfa_status

        db = SessionLocal()
        try:
            return bool(admin_mfa_status(db).get("mfa_enabled"))
        finally:
            db.close()
    except HTTPException:
        raise
    except Exception as exc:
        logger.warning("Unable to load admin MFA status: %s", exc)
        raise HTTPException(
            status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
            detail="Authentication backend unavailable",
        ) from exc


def _is_mfa_setup_path(request: Request) -> bool:
    """Paths allowed with Basic auth while MFA is not yet enrolled (when MFA is required)."""
    path = (request.url.path or "").rstrip("/") or "/"
    allowed_prefixes = (
        "/auth/mfa",
        "/auth/login",
        "/auth/logout",
        "/health",
    )
    return any(path == p or path.startswith(p + "/") for p in allowed_prefixes)


def require_admin(
    request: Request,
    credentials: HTTPBasicCredentials | None = Depends(security),
    bearer: HTTPAuthorizationCredentials | None = Depends(bearer_security),
) -> str:
    """Authenticate API admin via Bearer access token (after MFA) or HTTP Basic.

    When MFA is enabled, Basic alone is rejected unless LIMRISTEM_MAIL_MFA_ALLOW_BASIC_WITHOUT_TOKEN=yes.
    When LIMRISTEM_MAIL_MFA_REQUIRED=yes (default) and MFA is not enrolled, Basic is only allowed
    on MFA setup endpoints so the operator can enroll TOTP/SMS.
    """
    address = client_address(request)
    ensure_auth_storage()
    if is_rate_limited(address):
        raise HTTPException(
            status_code=status.HTTP_429_TOO_MANY_REQUESTS,
            detail="Too many authentication attempts",
            headers={"Retry-After": str(settings.api_auth_block_seconds)},
        )

    # Prefer Bearer access token issued by /auth after password (+ MFA).
    if bearer and bearer.scheme.lower() == "bearer" and bearer.credentials:
        from .mfa import load_access_token

        token_data = load_access_token(bearer.credentials)
        if token_data and secrets.compare_digest(str(token_data.get("username") or ""), settings.api_admin_user):
            clear_auth_failures(address)
            return str(token_data["username"])
        register_auth_failure(address)
        raise HTTPException(
            status_code=status.HTTP_401_UNAUTHORIZED,
            detail="Invalid or expired access token",
            headers={"WWW-Authenticate": "Bearer"},
        )

    if credentials is None:
        raise HTTPException(
            status_code=status.HTTP_401_UNAUTHORIZED,
            detail="Unauthorized",
            headers={"WWW-Authenticate": 'Bearer, Basic realm="limristem-mail"'},
        )

    # Always evaluate both sides to reduce username/password timing side-channels.
    correct_user = secrets.compare_digest(credentials.username, settings.api_admin_user)
    try:
        correct_pass = verify_admin_secret(credentials.password)
    except HTTPException:
        register_auth_failure(address)
        raise
    if not (correct_user and correct_pass):
        register_auth_failure(address)
        raise HTTPException(
            status_code=status.HTTP_401_UNAUTHORIZED,
            detail="Unauthorized",
            headers={"WWW-Authenticate": 'Bearer, Basic realm="limristem-mail"'},
        )

    mfa_enrolled = _admin_mfa_required()
    if mfa_enrolled and not settings.mfa_allow_basic_without_token:
        raise HTTPException(
            status_code=status.HTTP_401_UNAUTHORIZED,
            detail="MFA required: POST /auth/login then complete MFA and use Bearer access_token",
            headers={"WWW-Authenticate": "Bearer"},
        )

    # Production default: force MFA enrollment before unrestricted Basic admin access.
    if (
        getattr(settings, "mfa_required", True)
        and not mfa_enrolled
        and not settings.mfa_allow_basic_without_token
        and not _is_mfa_setup_path(request)
    ):
        raise HTTPException(
            status_code=status.HTTP_403_FORBIDDEN,
            detail=(
                "MFA enrollment required: enroll TOTP via POST /auth/mfa/totp/enroll "
                "(or configure SMS MFA), then complete MFA. "
                "Emergency only: LIMRISTEM_MAIL_MFA_ALLOW_BASIC_WITHOUT_TOKEN=yes "
                "or LIMRISTEM_MAIL_MFA_REQUIRED=no"
            ),
        )

    clear_auth_failures(address)
    return credentials.username


def issue_panel_mfa_pending_cookie(response: Response, pending_id: str, max_age: int) -> None:
    response.set_cookie(
        PANEL_MFA_PENDING_COOKIE,
        pending_id,
        max_age=max_age,
        httponly=True,
        secure=(settings.ssl_mode != "plain"),
        samesite="strict",
        path=PANEL_COOKIE_PATH,
    )


def clear_panel_mfa_pending_cookie(response: Response) -> None:
    response.delete_cookie(PANEL_MFA_PENDING_COOKIE, path=PANEL_COOKIE_PATH)


def panel_mfa_pending_id(request: Request) -> str | None:
    return request.cookies.get(PANEL_MFA_PENDING_COOKIE)
