"""Server-side session management utilities.

Provides helpers for creating, validating, and revoking user sessions.
Sessions are tracked in the ``user_sessions`` table and referenced by a
cryptographically random token stored in the browser cookie.  This enables
the "log off everywhere" feature and per-session revocation.
"""

from __future__ import annotations

import logging
import secrets
from datetime import datetime, timedelta, timezone

from sqlalchemy.orm import Session

from app.config import settings
from app.models import ApiToken, QRLoginChallenge, UserSession

logger = logging.getLogger(__name__)


def _ensure_tz_aware(dt: datetime | None) -> datetime | None:
    """Return *dt* with UTC tzinfo if it is naive, or unchanged if already aware.

    SQLite does not persist timezone information, so datetimes read back from
    the database are offset-naive.  This helper normalises them for safe
    comparison with ``datetime.now(timezone.utc)``.
    """
    if dt is not None and dt.tzinfo is None:
        return dt.replace(tzinfo=timezone.utc)
    return dt


def get_session_lifetime_days() -> int:
    """Return the effective session lifetime in days.

    If ``session_lifetime_custom_days`` is set it takes precedence over
    ``session_lifetime_days``.
    """
    custom = getattr(settings, "session_lifetime_custom_days", None)
    if custom is not None and isinstance(custom, int) and custom > 0:
        return custom
    return max(1, getattr(settings, "session_lifetime_days", 30))


def get_session_max_age_seconds() -> int:
    """Return the session max-age in seconds for the cookie."""
    return get_session_lifetime_days() * 86400


def create_session(
    db: Session,
    user_id: str,
    ip_address: str | None = None,
    user_agent: str | None = None,
) -> UserSession:
    """Create a new server-side session record.

    Args:
        db: Database session.
        user_id: Stable owner identifier.
        ip_address: Client IP address.
        user_agent: Client User-Agent header.

    Returns:
        The newly created ``UserSession`` instance.
    """
    session_token = secrets.token_urlsafe(64)
    now = datetime.now(timezone.utc)
    lifetime_days = get_session_lifetime_days()
    expires_at = now + timedelta(days=lifetime_days)

    device_info = _parse_device_info(user_agent)

    user_session = UserSession(
        session_token=session_token,
        user_id=user_id,
        ip_address=ip_address,
        user_agent=(user_agent or "")[:512],
        device_info=device_info,
        created_at=now,
        last_active_at=now,
        expires_at=expires_at,
    )
    try:
        db.add(user_session)
        db.commit()
        db.refresh(user_session)
    except Exception:
        db.rollback()
        logger.exception("Failed to create session for user_id=%s", user_id)
        raise

    logger.info(
        "[SESSION] Created session id=%s user=%s device=%r expires=%s",
        user_session.id,
        user_id,
        device_info,
        expires_at.isoformat(),
    )
    return user_session


def validate_session(db: Session, session_token: str) -> UserSession | None:
    """Validate a session token and return the session if valid.

    A session is valid when:
    * It exists in the database.
    * ``is_revoked`` is ``False``.
    * ``expires_at`` is in the future.

    Side-effect: updates ``last_active_at`` on valid sessions.

    Returns:
        The ``UserSession`` if valid, else ``None``.
    """
    if not session_token:
        return None

    now = datetime.now(timezone.utc)
    user_session = db.query(UserSession).filter(UserSession.session_token == session_token).first()

    if not user_session:
        logger.debug("[SESSION] Token not found in database")
        return None

    if user_session.is_revoked:
        logger.debug("[SESSION] Session id=%s is revoked", user_session.id)
        return None

    if user_session.expires_at:
        expires = _ensure_tz_aware(user_session.expires_at)
        if expires < now:
            logger.debug("[SESSION] Session id=%s has expired", user_session.id)
            return None

    # Update last_active_at (throttled to avoid excessive writes)
    last_active = _ensure_tz_aware(user_session.last_active_at)
    if not last_active or (now - last_active).total_seconds() > 60:
        try:
            user_session.last_active_at = now
            db.commit()
        except Exception:
            db.rollback()
            logger.debug("[SESSION] Failed to update last_active_at for session id=%s", user_session.id)

    return user_session


def revoke_session(db: Session, session_id: int, user_id: str) -> bool:
    """Revoke a single session by ID.

    Args:
        db: Database session.
        session_id: The session record ID to revoke.
        user_id: The owner — ensures a user can only revoke their own sessions.

    Returns:
        ``True`` if the session was found and revoked, ``False`` otherwise.
    """
    user_session = db.get(UserSession, session_id)
    if not user_session or user_session.user_id != user_id:
        return False

    now = datetime.now(timezone.utc)
    user_session.is_revoked = True
    user_session.revoked_at = now
    try:
        db.commit()
    except Exception:
        db.rollback()
        raise

    logger.info("[SESSION] Revoked session id=%s user=%s", session_id, user_id)
    return True


def revoke_all_sessions(
    db: Session,
    user_id: str,
    *,
    except_session_id: int | None = None,
    revoke_api_tokens: bool = True,
) -> int:
    """Revoke all active sessions for a user ("log off everywhere").

    Args:
        db: Database session.
        user_id: The owner whose sessions should be revoked.
        except_session_id: If provided, keep this session active (the
            current browser session).
        revoke_api_tokens: If ``True``, also revoke all active API tokens.

    Returns:
        Number of sessions revoked.
    """
    now = datetime.now(timezone.utc)
    query = db.query(UserSession).filter(
        UserSession.user_id == user_id,
        UserSession.is_revoked.is_(False),
    )
    if except_session_id is not None:
        query = query.filter(UserSession.id != except_session_id)

    sessions = query.all()
    count = 0
    for s in sessions:
        s.is_revoked = True
        s.revoked_at = now
        count += 1

    if revoke_api_tokens:
        tokens = (
            db.query(ApiToken)
            .filter(
                ApiToken.owner_id == user_id,
                ApiToken.is_active.is_(True),
            )
            .all()
        )
        for t in tokens:
            t.is_active = False
            t.revoked_at = now

    try:
        db.commit()
    except Exception:
        db.rollback()
        raise

    logger.info(
        "[SESSION] Revoked all sessions for user=%s (count=%d, except_session_id=%s, tokens_revoked=%s)",
        user_id,
        count,
        except_session_id,
        revoke_api_tokens,
    )
    return count


def list_user_sessions(db: Session, user_id: str) -> list[UserSession]:
    """Return all non-revoked, non-expired sessions for a user.

    Results are ordered by most recently active first.
    """
    now = datetime.now(timezone.utc)
    sessions = (
        db.query(UserSession)
        .filter(
            UserSession.user_id == user_id,
            UserSession.is_revoked.is_(False),
        )
        .order_by(UserSession.last_active_at.desc())
        .all()
    )
    # Filter expired sessions in Python to handle timezone-naive datetimes (SQLite)
    result = []
    for s in sessions:
        expires = _ensure_tz_aware(s.expires_at)
        if expires and expires > now:
            result.append(s)
    return result


def cleanup_expired_sessions(db: Session) -> int:
    """Delete sessions that expired more than 7 days ago.

    Intended to be called periodically (e.g. via Celery beat) to keep the
    table from growing unbounded.

    Returns:
        Number of rows deleted.
    """
    cutoff = datetime.now(timezone.utc) - timedelta(days=7)
    count = db.query(UserSession).filter(UserSession.expires_at < cutoff).delete(synchronize_session=False)
    try:
        db.commit()
    except Exception:
        db.rollback()
        raise
    if count:
        logger.info("[SESSION] Cleaned up %d expired sessions", count)
    return count


# ---------------------------------------------------------------------------
# QR login helpers
# ---------------------------------------------------------------------------


def create_qr_challenge(db: Session, user_id: str, ip_address: str | None = None) -> QRLoginChallenge:
    """Create a new QR login challenge.

    Args:
        db: Database session.
        user_id: The authenticated web user creating the challenge.
        ip_address: IP address of the web client.

    Returns:
        The newly created ``QRLoginChallenge``.
    """
    token = secrets.token_urlsafe(64)
    ttl = getattr(settings, "qr_login_challenge_ttl_seconds", 120)
    now = datetime.now(timezone.utc)
    expires_at = now + timedelta(seconds=ttl)

    challenge = QRLoginChallenge(
        challenge_token=token,
        user_id=user_id,
        created_by_ip=ip_address,
        created_at=now,
        expires_at=expires_at,
    )
    try:
        db.add(challenge)
        db.commit()
        db.refresh(challenge)
    except Exception:
        db.rollback()
        logger.exception("Failed to create QR login challenge for user_id=%s", user_id)
        raise

    logger.info("[QR_AUTH] Challenge created: id=%s user=%s expires=%s", challenge.id, user_id, expires_at.isoformat())
    return challenge


def validate_qr_challenge(db: Session, challenge_token: str) -> QRLoginChallenge | None:
    """Validate a QR challenge token without claiming it.

    Returns the challenge if it exists, is not expired, not claimed,
    and not cancelled.  Returns ``None`` otherwise.
    """
    if not challenge_token:
        return None

    now = datetime.now(timezone.utc)
    challenge = db.query(QRLoginChallenge).filter(QRLoginChallenge.challenge_token == challenge_token).first()

    if not challenge:
        return None
    if challenge.is_claimed or challenge.is_cancelled:
        return None

    expires = _ensure_tz_aware(challenge.expires_at)
    if expires and expires < now:
        return None

    return challenge


def claim_qr_challenge(
    db: Session,
    challenge_token: str,
    device_name: str = "Mobile App",
    ip_address: str | None = None,
) -> dict | None:
    """Claim a QR challenge and issue an API token.

    This is the critical security path.  The challenge is validated,
    marked as claimed atomically, and an API token is issued for the
    user who created the challenge.

    Args:
        db: Database session.
        challenge_token: The token from the QR code.
        device_name: Name provided by the mobile app.
        ip_address: IP address of the claiming mobile device.

    Returns:
        Dict with ``token`` (plaintext), ``token_id``, ``name``, ``owner_id``
        and ``created_at`` on success, or ``None`` if the challenge is invalid.
    """
    from app.api.api_tokens import generate_api_token, hash_token

    challenge = validate_qr_challenge(db, challenge_token)
    if not challenge:
        logger.warning("[QR_AUTH] Invalid or expired challenge token attempted")
        return None

    now = datetime.now(timezone.utc)

    # Mark as claimed first to prevent race conditions
    challenge.is_claimed = True
    challenge.claimed_at = now
    challenge.claimed_by_ip = ip_address
    challenge.device_name = device_name

    # Generate API token for the mobile app
    token_name = f"Mobile App (QR) – {device_name}"
    plaintext = generate_api_token()
    token_hash_value = hash_token(plaintext)
    prefix = plaintext[:12]

    db_token = ApiToken(
        owner_id=challenge.user_id,
        name=token_name,
        token_hash=token_hash_value,
        token_prefix=prefix,
    )

    try:
        db.add(db_token)
        db.flush()
        challenge.issued_token_id = db_token.id
        db.commit()
        db.refresh(db_token)
    except Exception:
        db.rollback()
        logger.exception("[QR_AUTH] Failed to issue token for challenge id=%s", challenge.id)
        raise

    logger.info(
        "[QR_AUTH] Challenge claimed: id=%s user=%s device=%r token_id=%s",
        challenge.id,
        challenge.user_id,
        device_name,
        db_token.id,
    )

    return {
        "token": plaintext,
        "token_id": db_token.id,
        "name": token_name,
        "owner_id": challenge.user_id,
        "created_at": db_token.created_at,
    }


def get_challenge_status(db: Session, challenge_id: int, user_id: str) -> dict | None:
    """Get the current status of a QR challenge (for polling from the web UI).

    Returns:
        Dict with ``status`` ("pending", "claimed", "expired", "cancelled")
        and metadata, or ``None`` if the challenge doesn't belong to the user.
    """
    challenge = db.get(QRLoginChallenge, challenge_id)
    if not challenge or challenge.user_id != user_id:
        return None

    now = datetime.now(timezone.utc)
    expires = _ensure_tz_aware(challenge.expires_at)

    if challenge.is_claimed:
        status = "claimed"
    elif challenge.is_cancelled:
        status = "cancelled"
    elif expires and expires < now:
        status = "expired"
    else:
        status = "pending"

    return {
        "id": challenge.id,
        "status": status,
        "device_name": challenge.device_name,
        "claimed_at": challenge.claimed_at,
        "expires_at": challenge.expires_at,
    }


def _parse_device_info(user_agent: str | None) -> str | None:
    """Extract a human-readable device description from User-Agent.

    This is a lightweight parser — not a full UA library — that covers
    the most common browsers and platforms.
    """
    if not user_agent:
        return None

    ua = user_agent.lower()

    # Platform detection
    platform = "Unknown"
    if "iphone" in ua:
        platform = "iPhone"
    elif "ipad" in ua:
        platform = "iPad"
    elif "android" in ua:
        platform = "Android"
    elif "macintosh" in ua or "mac os" in ua:
        platform = "macOS"
    elif "windows" in ua:
        platform = "Windows"
    elif "linux" in ua:
        platform = "Linux"
    elif "cros" in ua:
        platform = "ChromeOS"

    # Browser detection
    browser = "Unknown Browser"
    if "edg/" in ua or "edge/" in ua:
        browser = "Edge"
    elif "opr/" in ua or "opera" in ua:
        browser = "Opera"
    elif "chrome/" in ua and "safari/" in ua:
        browser = "Chrome"
    elif "safari/" in ua and "chrome/" not in ua:
        browser = "Safari"
    elif "firefox/" in ua:
        browser = "Firefox"
    elif "docuelevate" in ua:
        browser = "DocuElevate App"

    return f"{browser} on {platform}"
