# backend/operator_auth.py
"""Operator authentication: the deny-by-default gate, session cookie, login +
lockout, and the small data helpers shared by the routes and the CLI. Reuses the
argon2 hasher (auth_passwords) and the HMAC signer (auth_cookies).

The flag and SessionLocal are read as module globals at call time so tests can
monkeypatch them."""
import os
import time
import uuid
from datetime import datetime, timedelta
from urllib.parse import quote

from fastapi import Request, HTTPException
from fastapi.responses import JSONResponse, RedirectResponse

from backend.models import OperatorUser
from backend.auth_passwords import hash_password, verify_password, generate_password
from backend.auth_cookies import sign, read, COOKIE_SECURE
from backend.database import SessionLocal

# Feature flag — OFF by default. When off, the gate and require_role both pass
# everything through and the app behaves exactly as it does today.
OPERATOR_AUTH_ENABLED = os.getenv("OPERATOR_AUTH_ENABLED", "false").lower() in ("1", "true", "yes")

COOKIE_NAME = "tv_session"
COOKIE_MAX_AGE = 60 * 60 * 24 * 30  # 30 days ("remember this device")
MAX_LOGIN_FAILURES = 5
LOCK_MINUTES = 15

# Role ladder — a rank map so checks read naturally and 'operator' slots in later.
_ROLE_RANK = {"operator": 10, "admin": 20, "superadmin": 30}

# A throwaway hash used only to equalize verify time on the unknown-email path,
# so a missing account can't be distinguished from a wrong password by timing
# (no user-enumeration). The value never authenticates anything.
_DUMMY_PASSWORD_HASH = hash_password("operator-auth-timing-equalizer")


def role_at_least(role: str, minimum: str) -> bool:
    """True iff `role` ranks at or above `minimum`. Unknown roles rank as 0."""
    return _ROLE_RANK.get(role, 0) >= _ROLE_RANK[minimum]


def _norm_email(email: str) -> str:
    return (email or "").strip().lower()


def make_operator_cookie(uid: str, iat: int = None) -> str:
    """Sign a tv_session value for a user id. iat defaults to now; pass an explicit
    iat when you bump sessions_valid_from to that same instant (change-password)."""
    return sign({"uid": uid, "iat": int(iat if iat is not None else time.time())})


def current_operator(request, db):
    """Resolve the OperatorUser for a request's tv_session cookie, or None.
    Re-validated against the DB every call: a disabled / locked / password-changed
    user drops on the next request. Used by the gate middleware (with its own
    session) — does not raise."""
    data = read(request.cookies.get(COOKIE_NAME), COOKIE_MAX_AGE)
    if not data:
        return None
    uid, iat = data.get("uid"), data.get("iat")
    if not uid or not isinstance(iat, (int, float)):
        return None
    user = db.query(OperatorUser).filter_by(id=uid).first()
    if not user or not user.active:
        return None
    if user.locked_until and user.locked_until > datetime.utcnow():
        return None
    if user.sessions_valid_from and datetime.utcfromtimestamp(int(iat)) < user.sessions_valid_from:
        return None
    return user


def register_login_failure(db, user) -> None:
    """Increment a user's failure counter and lock them out past the threshold."""
    user.failed_login_count = (user.failed_login_count or 0) + 1
    if user.failed_login_count >= MAX_LOGIN_FAILURES:
        user.locked_until = datetime.utcnow() + timedelta(minutes=LOCK_MINUTES)
    db.commit()


def authenticate(db, email, password):
    """Return (user, "ok") on success, (None, "locked") if locked out, else
    (None, "bad"). Never reveals whether the email exists: an unknown email runs
    the same argon2 verify (against a dummy hash) as a wrong password, so neither
    the response text nor its timing distinguishes the two."""
    user = db.query(OperatorUser).filter_by(email=_norm_email(email)).first()
    if user and user.locked_until and user.locked_until > datetime.utcnow():
        return None, "locked"
    password_ok = verify_password(password, user.password_hash if user else _DUMMY_PASSWORD_HASH)
    if not user or not user.active or not password_ok:
        if user:
            register_login_failure(db, user)
        return None, "bad"
    user.failed_login_count = 0
    user.locked_until = None
    user.last_login_at = datetime.utcnow()
    db.commit()
    return user, "ok"


def create_operator(db, email, name, role, password=None, must_change=None):
    """Create an operator. With no password, generate a temp one and force a change
    (must_change defaults True). With a password, must_change defaults False.
    Returns (user, raw_password_to_show_once). Raises ValueError on dup/bad role."""
    email = _norm_email(email)
    if role not in _ROLE_RANK:
        raise ValueError(f"unknown role {role!r}")
    if db.query(OperatorUser).filter_by(email=email).first():
        raise ValueError(f"operator {email} already exists")
    if password is None:
        password = generate_password()
        if must_change is None:
            must_change = True
    elif must_change is None:
        must_change = False
    user = OperatorUser(id=str(uuid.uuid4()), email=email, display_name=name,
                        password_hash=hash_password(password), role=role,
                        active=True, must_change_password=must_change)
    db.add(user)
    db.commit()
    return user, password


def reset_operator_password(db, user) -> str:
    """Generate a fresh temp password, force a change, log the user out everywhere.
    Returns the raw password to show once."""
    raw = generate_password()
    user.password_hash = hash_password(raw)
    user.must_change_password = True
    user.failed_login_count = 0
    user.locked_until = None
    user.sessions_valid_from = datetime.utcnow().replace(microsecond=0)
    db.commit()
    return raw


def change_own_password(db, user, new_password) -> int:
    """Set a user's own new password, clear the forced-change flag, and bump
    sessions_valid_from to the returned iat — the caller mints the replacement
    cookie with that exact iat so it stays valid while older cookies die."""
    new_iat = int(time.time())
    user.password_hash = hash_password(new_password)
    user.must_change_password = False
    user.sessions_valid_from = datetime.utcfromtimestamp(new_iat)
    db.commit()
    return new_iat


def set_operator_active(db, user, active: bool):
    user.active = bool(active)
    db.commit()
    return user


def set_operator_role(db, user, role: str):
    if role not in _ROLE_RANK:
        raise ValueError(f"unknown role {role!r}")
    user.role = role
    db.commit()
    return user


# Routes reachable with no login. A new route added next year is gated by default.
_EXEMPT_EXACT = {
    "/login", "/logout", "/health",
    "/manifest.json", "/sw.js", "/favicon.ico", "/offline-db.js",
    "/portal",                       # portal home (its own auth)
    # machine endpoints — LAN-only, automated, no human (watchers/heartbeats):
    "/emitters/report", "/api/series3/heartbeat", "/api/series4/heartbeat",
}
_EXEMPT_PREFIX = ("/static/", "/portal/")


def _is_exempt(path: str) -> bool:
    return path in _EXEMPT_EXACT or path.startswith(_EXEMPT_PREFIX)


async def operator_gate(request: Request, call_next):
    """Deny-by-default gate. Flag off → pass through (app as today). Flag on →
    exempt paths pass; otherwise require a valid operator session, stash it on
    request.state.operator, and force a password change when pending."""
    if not OPERATOR_AUTH_ENABLED:
        return await call_next(request)

    # CORS preflight carries no auth and must reach CORSMiddleware, not the gate.
    if request.method == "OPTIONS":
        return await call_next(request)

    path = request.url.path
    if _is_exempt(path):
        return await call_next(request)

    db = SessionLocal()
    try:
        user = current_operator(request, db)
        if user is not None:
            db.expunge(user)          # detach a fully-loaded row so we can close now
    finally:
        db.close()

    if user is None:
        if path.startswith("/api/"):
            return JSONResponse({"detail": "Not authenticated"}, status_code=401)
        return RedirectResponse(f"/login?next={quote(path)}", status_code=303)

    if user.must_change_password and path not in ("/change-password", "/logout"):
        if path.startswith("/api/"):
            return JSONResponse({"detail": "Password change required"}, status_code=403)
        return RedirectResponse("/change-password", status_code=303)

    request.state.operator = user
    return await call_next(request)


def require_role(minimum: str):
    """Dependency factory: require a logged-in operator ranked >= `minimum`.
    Respects the flag (off → pass through). When on, the middleware has already
    set request.state.operator before this runs."""
    def _dep(request: Request):
        if not OPERATOR_AUTH_ENABLED:
            return None
        user = getattr(request.state, "operator", None)
        if user is None:
            raise HTTPException(status_code=401, detail="Not authenticated")
        if not role_at_least(user.role, minimum):
            raise HTTPException(status_code=403, detail="Insufficient permissions")
        return user
    return _dep
