"""Authentication: password hashing, user sessions + CSRF, the legacy
shared-token gate, and device resolution.

Three independent credential classes:
- User sessions (cookie "session", server-side sessions table, per-
  session CSRF token required on mutating requests) -- humans.
- The legacy shared MANAGEMENT_TOKEN (env-only). Still accepted on
  browser routes so the deployed frame's on-panel manage QR (which
  embeds ?token=) keeps working until Phase C replaces it with the
  limited /m/ page; CSRF doesn't apply to it (it's explicit per-request
  credential, not an ambient cookie a cross-site request could ride).
- Device credentials (?id= + ?token=, see require_device below).
"""

from __future__ import annotations

import hashlib
import hmac
import logging
import os
import secrets
import time

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

from .db import get_db
from .migration import new_device_token, new_manage_token
from .models import Frame, PasswordResetToken, PendingClaim, ServerSettings, User, UserFrame, UserSession

logger = logging.getLogger(__name__)

MANAGEMENT_TOKEN_COOKIE = "mgmt_token"
SESSION_COOKIE = "session"
SESSION_LIFETIME_S = 30 * 86400
SESSION_REFRESH_BELOW_S = 15 * 86400  # rolling expiry: extend when under this much left
PASSWORD_RESET_TOKEN_LIFETIME_S = 3600

# stdlib scrypt instead of a passlib/argon2 dependency: zero new deps,
# and the parameters are baked into each stored hash so they can be
# raised later without invalidating existing ones.
_SCRYPT_N = 16384
_SCRYPT_R = 8
_SCRYPT_P = 1


def hash_password(password: str) -> str:
    salt = os.urandom(16)
    digest = hashlib.scrypt(
        password.encode(), salt=salt, n=_SCRYPT_N, r=_SCRYPT_R, p=_SCRYPT_P
    )
    return f"scrypt${_SCRYPT_N}${_SCRYPT_R}${_SCRYPT_P}${salt.hex()}${digest.hex()}"


def verify_password(password: str, stored: str) -> bool:
    try:
        scheme, n, r, p, salt_hex, hash_hex = stored.split("$")
        if scheme != "scrypt":
            return False
        digest = hashlib.scrypt(
            password.encode(), salt=bytes.fromhex(salt_hex), n=int(n), r=int(r), p=int(p)
        )
        return hmac.compare_digest(digest.hex(), hash_hex)
    except (ValueError, AttributeError):
        return False


def _hash_session_token(value: str) -> str:
    return hashlib.sha256(value.encode()).hexdigest()


def create_session(db: Session, user: User) -> tuple[str, UserSession]:
    """Returns (cookie_value, session row). Only the sha256 of the cookie
    value is stored, so a leaked database doesn't yield usable cookies."""
    cookie_value = secrets.token_urlsafe(32)
    now = time.time()
    session = UserSession(
        token_hash=_hash_session_token(cookie_value),
        user_id=user.id,
        csrf_token=secrets.token_urlsafe(32),
        created_at=now,
        expires_at=now + SESSION_LIFETIME_S,
    )
    db.add(session)
    # Opportunistic prune -- no background scheduler in this project.
    for stale in db.scalars(select(UserSession).where(UserSession.expires_at < now)):
        db.delete(stale)
    db.commit()
    return cookie_value, session


def destroy_session(db: Session, request: Request) -> None:
    cookie_value = request.cookies.get(SESSION_COOKIE)
    if not cookie_value:
        return
    session = db.scalars(
        select(UserSession).where(UserSession.token_hash == _hash_session_token(cookie_value))
    ).first()
    if session is not None:
        db.delete(session)
        db.commit()


def current_session(request: Request, db: Session) -> UserSession | None:
    cookie_value = request.cookies.get(SESSION_COOKIE)
    if not cookie_value:
        return None
    session = db.scalars(
        select(UserSession).where(UserSession.token_hash == _hash_session_token(cookie_value))
    ).first()
    now = time.time()
    if session is None or session.expires_at < now:
        return None
    if session.expires_at - now < SESSION_REFRESH_BELOW_S:
        session.expires_at = now + SESSION_LIFETIME_S
        db.commit()
    return session


def current_user(request: Request, db: Session) -> User | None:
    session = current_session(request, db)
    if session is None:
        return None
    return db.get(User, session.user_id)


def users_exist(db: Session) -> bool:
    return db.scalars(select(User).limit(1)).first() is not None


def get_server_settings(db: Session) -> ServerSettings:
    """The SMTP config singleton -- migration.py guarantees row id=1
    exists (created at startup if missing), so this is never None."""
    settings = db.get(ServerSettings, 1)
    assert settings is not None
    return settings


def create_password_reset_token(db: Session, user: User) -> str:
    token = secrets.token_urlsafe(32)
    now = time.time()
    # Opportunistic prune, same pattern as sessions/pending claims.
    for stale in db.scalars(select(PasswordResetToken).where(PasswordResetToken.expires_at < now)):
        db.delete(stale)
    db.add(PasswordResetToken(
        token=token, user_id=user.id, created_at=now,
        expires_at=now + PASSWORD_RESET_TOKEN_LIFETIME_S,
    ))
    db.commit()
    return token


def consume_password_reset_token(db: Session, token: str) -> User | None:
    """Looks up the token and, if valid, deletes it (single-use) and
    returns the user it was issued for. None for an unknown/expired
    token -- callers show a generic error either way."""
    row = db.get(PasswordResetToken, token)
    if row is None or row.expires_at < time.time():
        return None
    user = db.get(User, row.user_id)
    db.delete(row)
    db.commit()
    return user


def _csrf_ok(request: Request, session: UserSession) -> bool:
    supplied = request.headers.get("X-CSRF-Token") or ""
    return hmac.compare_digest(supplied, session.csrf_token)


def require_user_api(request: Request, db: Session = Depends(get_db)) -> User:
    """JSON-API dependency: a logged-in user, with CSRF enforced on
    mutating methods (the session rides an ambient cookie; the CSRF
    header is what proves the request came from our own JS, not a
    cross-site form)."""
    session = current_session(request, db)
    if session is None:
        raise HTTPException(401, "Not logged in")
    if request.method not in ("GET", "HEAD", "OPTIONS") and not _csrf_ok(request, session):
        raise HTTPException(403, "Missing or invalid CSRF token")
    user = db.get(User, session.user_id)
    if user is None:
        raise HTTPException(401, "Not logged in")
    return user


def require_admin_api(request: Request, db: Session = Depends(get_db)) -> User:
    user = require_user_api(request, db)
    if not user.is_admin:
        raise HTTPException(403, "Admin only")
    return user


def user_frames(db: Session, user: User) -> list[Frame]:
    """The frames this user sees in their sidebar: linked ones, or all of
    them for an admin (admins are the household operators -- they see
    unclaimed/new frames too, that's how those get adopted)."""
    if user.is_admin:
        return list(db.scalars(select(Frame).order_by(Frame.id)))
    return list(
        db.scalars(
            select(Frame)
            .join(UserFrame, UserFrame.frame_id == Frame.id)
            .where(UserFrame.user_id == user.id)
            .order_by(Frame.id)
        )
    )


def can_view_frame(db: Session, user: User, frame: Frame) -> bool:
    return user.is_admin or db.get(UserFrame, (user.id, frame.id)) is not None


def require_frame_view(
    frame_id: int, request: Request, db: Session = Depends(get_db)
) -> Frame:
    """JSON-API dependency: a logged-in user who is linked to this frame
    (or an admin). 404 -- not 403 -- for frames outside the user's view,
    so the API doesn't confirm which frame ids exist."""
    user = require_user_api(request, db)
    frame = db.get(Frame, frame_id)
    if frame is None or not can_view_frame(db, user, frame):
        raise HTTPException(404, "No such frame")
    return frame


def require_frame_control(
    frame_id: int, request: Request, db: Session = Depends(get_db)
) -> Frame:
    """View access plus the soft control lock: only the user currently
    holding control may mutate settings/queue. The 409 payload names the
    holder so the UI can offer "take control" instead of a dead end.
    Physical device buttons don't go through this -- device actions are
    device actions."""
    user = require_user_api(request, db)
    frame = db.get(Frame, frame_id)
    if frame is None or not can_view_frame(db, user, frame):
        raise HTTPException(404, "No such frame")
    if frame.controlled_by_user_id != user.id:
        holder = frame.controlled_by
        raise HTTPException(
            409,
            {
                "error": "not_controller",
                "holder": (holder.display_name or holder.username) if holder else None,
            },
        )
    return frame


def management_token() -> str:
    """The legacy shared secret. Env-only, never stored -- same as the old
    server, where the env var overrode anything on disk on every load."""
    return os.environ.get("MANAGEMENT_TOKEN", "")


def browser_token_valid(request: Request) -> bool:
    """The legacy shared-token check. No MANAGEMENT_TOKEN configured means
    token-holders don't exist -- but unlike Phase A this no longer means
    "open": once users exist, sessions are the primary gate and this is
    only the compatibility path for the deployed frame's manage QR
    (?token=) until Phase C. Empty token => not valid (sessions rule)."""
    token = management_token()
    if not token:
        return False
    supplied = request.query_params.get("token") or request.cookies.get(MANAGEMENT_TOKEN_COOKIE)
    return supplied is not None and supplied == token


def require_browser(request: Request, db: Session = Depends(get_db)) -> User | None:
    """Dependency for the web UI's /api/* routes: a real user session
    (CSRF-checked on mutations, returns the User), or the legacy shared
    token (returns None -- token bearers act as an anonymous operator,
    exactly the pre-user model). While NO users exist yet (fresh install
    or freshly migrated, before /setup has been run) the API stays open
    if no MANAGEMENT_TOKEN is set -- the Phase A/legacy behavior --
    since there's nobody to log in as yet."""
    session = current_session(request, db)
    if session is not None:
        if request.method not in ("GET", "HEAD", "OPTIONS") and not _csrf_ok(request, session):
            raise HTTPException(403, "Missing or invalid CSRF token")
        user = db.get(User, session.user_id)
        if user is not None:
            return user
    if browser_token_valid(request):
        return None
    if not users_exist(db) and not management_token():
        return None
    raise HTTPException(401, "Not logged in")


def _register_frame(db: Session, device_id: str) -> Frame:
    """A device id we've never seen: self-register it as an unclaimed
    frame (this fires from ANY /frame/* route -- the wake cycle hits
    /frame/image before /frame/config). If a user already submitted a
    claim for this id (they beat the device to the server after
    provisioning), attach it now."""
    frame = Frame(
        name=f"Frame {device_id[-6:]}",
        device_id=device_id,
        device_token=new_device_token(),
        manage_token=new_manage_token(),
        created_at=time.time(),
    )
    db.add(frame)
    db.flush()

    now = time.time()
    # Opportunistically prune expired claims while we're here.
    for stale in db.scalars(select(PendingClaim).where(PendingClaim.expires_at < now)):
        db.delete(stale)

    pending = db.get(PendingClaim, device_id)
    if pending is not None and pending.expires_at >= now:
        frame.owner_user_id = pending.user_id
        frame.claimed_at = now
        db.add(UserFrame(user_id=pending.user_id, frame_id=frame.id))
        db.delete(pending)
        logger.info("Frame %s self-registered and attached pending claim by user %d",
                    device_id, pending.user_id)
    else:
        logger.info("Frame %s self-registered (unclaimed)", device_id)
    return frame


def require_device(request: Request, db: Session = Depends(get_db)) -> Frame:
    """Resolves and authenticates the frame behind a /frame/* request.

    New firmware sends ?id=<12-hex-mac>&token=<per-frame device token>.
    Deployed legacy firmware sends only ?token=<shared MANAGEMENT_TOKEN>
    (or nothing, on an open server) -- those requests resolve to the
    unique legacy_token_enabled frame for as long as that migration
    window stays open. The first id-bearing request arriving with legacy
    credentials while the legacy frame has no device_id yet BINDS that id
    to it -- that's the moment the deployed frame comes back up on new
    firmware after its OTA, and it must not register as a second frame.
    """
    device_id = request.query_params.get("id", "").strip().lower()
    token = request.query_params.get("token", "")
    legacy = management_token()
    legacy_ok = not legacy or token == legacy

    if device_id:
        frame = db.scalars(select(Frame).where(Frame.device_id == device_id)).first()
        if frame is None:
            legacy_frame = db.scalars(
                select(Frame).where(Frame.legacy_token_enabled == True)  # noqa: E712
            ).first()
            if legacy_frame is not None and legacy_frame.device_id is None and legacy_ok:
                legacy_frame.device_id = device_id
                frame = legacy_frame
                logger.info("Bound device id %s to legacy frame #%d", device_id, frame.id)
            else:
                frame = _register_frame(db, device_id)
        else:
            token_ok = bool(token) and token == frame.device_token
            if token_ok and not frame.device_token_ack:
                frame.device_token_ack = True
                logger.info("Frame #%d acknowledged its device token", frame.id)
            if not token_ok:
                if frame.legacy_token_enabled and legacy_ok:
                    pass
                elif not frame.device_token_ack:
                    # Handshake window: the device registered but hasn't
                    # received its token yet (the wake cycle fetches the
                    # image BEFORE polling /frame/config, where the token
                    # is delivered) -- the id stays the credential, same
                    # trust level as the open registration that created
                    # the row. Closes permanently on the first
                    # authenticated request.
                    pass
                else:
                    raise HTTPException(401, "Missing or invalid access token")
    else:
        if not legacy_ok:
            raise HTTPException(401, "Missing or invalid access token")
        frame = db.scalars(
            select(Frame).where(Frame.legacy_token_enabled == True)  # noqa: E712
        ).first()
        if frame is None:
            # Nothing to resolve a no-id request to. migration.py always
            # creates frame #1 at startup, so this only happens if it was
            # deleted -- treat like an unknown device.
            raise HTTPException(401, "No frame accepts legacy credentials")

    frame.last_seen = time.time()
    db.commit()
    return frame
