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 HTTPBasic, HTTPBasicCredentials
from passlib.context import CryptContext
from redis.exceptions import RedisError

from .cache import get_cache
from .settings import get_settings

security = HTTPBasic()
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_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 is_rate_limited(address: str) -> bool:
    try:
        cache = get_cache()
        block_key = _rate_block_key(address)
        if cache.exists(block_key):
            return True
    except RedisError:
        pass
    return False


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:
        pass


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:
        pass


def verify_secret(password: str, password_hash: str | None, plain_password: str | None, label: str) -> bool:
    if password_hash:
        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
    if plain_password is None:
        raise HTTPException(
            status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
            detail=f"{label} credentials are not configured",
        )
    return secrets.compare_digest(password, plain_password)


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


def verify_panel_secret(password: str) -> bool:
    return verify_secret(
        password,
        settings.panel_admin_pass_hash or settings.api_admin_pass_hash,
        settings.panel_admin_pass or settings.api_admin_pass,
        "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 settings.panel_admin_pass
        or settings.api_admin_pass
        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
        or settings.panel_admin_pass
        or settings.api_admin_pass
    )
    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="strict",
        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="strict",
        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 require_admin(request: Request, credentials: HTTPBasicCredentials = Depends(security)) -> str:
    address = client_address(request)
    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)},
        )

    correct_user = secrets.compare_digest(credentials.username, settings.api_admin_user)
    correct_pass = verify_admin_secret(credentials.password)
    if not (correct_user and correct_pass):
        register_auth_failure(address)
        raise HTTPException(
            status_code=status.HTTP_401_UNAUTHORIZED,
            detail="Unauthorized",
            headers={"WWW-Authenticate": "Basic"},
        )

    clear_auth_failures(address)
    return credentials.username
