"""Trusted-device service.

A user may mark the browser they're signing in from as *trusted* at the end of a
login-verification flow, so the emailed OTP step is skipped on that device for a
configured number of days (Security_Design §19, "Device Recognition").

Security model:

* The **raw** device token is generated with :func:`secrets.token_urlsafe` and
  handed to the browser only, inside a secure ``HttpOnly`` / ``SameSite=Lax``
  cookie. The database stores solely a **salted SHA-256 hash** of it
  (:func:`app.marketplace.services._hash_value`, the exact helper used for
  ``ListingView`` / ``AdminAuditLog``) — a leaked table cannot be turned back
  into a usable cookie, and there is no second hashing implementation.
* Raw IP and user-agent are likewise stored only as salted hashes; a coarse,
  non-identifying ``device_label`` (e.g. "Chrome on Windows") is kept for the
  user's own device list. The label is **display/heuristic metadata only** — the
  cookie token is the sole authenticator (no browser fingerprinting).
* A device counts as trusted only while ``expires_at`` is in the future and
  ``revoked_at`` is NULL. Matching is by ``user_id`` **and** token hash, so one
  user's cookie can never satisfy another account.

This module owns all trusted-device logic; routes only call these functions.
Every lifecycle event is audited through the shared :func:`log_auth_event` sink
(no second logger).
"""

from __future__ import annotations

import secrets
from datetime import timedelta

from flask import current_app, render_template, url_for

from app.auth import otp_service
from app.auth.device_info import parse_user_agent
from app.auth.services import AuthEvent, log_auth_event
# Reuse the salted-SHA256 hasher and client-IP resolver already used for view
# tracking and admin audit — one implementation, no duplication.
from app.marketplace.services import _client_ip, _hash_value
from app.extensions import db
from app.models import TrustedDevice
from app.models.base import utcnow


def _cookie_name() -> str:
    return current_app.config.get("TRUSTED_DEVICE_COOKIE", "cc_trusted_device")


def _trust_days() -> int:
    return current_app.config.get("TRUSTED_DEVICE_DAYS", 30)


def _token_hash(raw_token: str) -> str | None:
    """Salted SHA-256 of the raw cookie token (never store the raw token)."""
    return _hash_value(raw_token)


# --- Coarse, non-PII device label ---------------------------------------------

def _device_label(request) -> str:
    """Best-effort "Browser on OS" label for the user's device list — reuses the
    shared :func:`app.auth.device_info.parse_user_agent` (one parser). Display-only,
    never an authentication signal."""
    ua = request.headers.get("User-Agent", "") if request else ""
    return parse_user_agent(ua)["label"]


# --- Trust check (rolling expiration) ------------------------------------------

def device_is_trusted(user, request) -> bool:
    """Return ``True`` when this browser holds a valid trusted-device cookie for
    ``user``.

    On a match the record's ``last_used_at`` is refreshed **and** ``expires_at``
    is rolled forward by ``TRUSTED_DEVICE_DAYS`` (rolling expiration: an actively
    used device stays trusted), and ``trusted_device_used`` is audited. A missing
    / unknown / expired / revoked token returns ``False``."""
    raw = request.cookies.get(_cookie_name()) if request else None
    if not raw:
        return False
    now = utcnow()
    device = db.session.scalar(
        db.select(TrustedDevice)
        .where(
            TrustedDevice.user_id == user.id,
            TrustedDevice.token_hash == _token_hash(raw),
            TrustedDevice.expires_at > now,
            TrustedDevice.revoked_at.is_(None),
        )
        .limit(1)
    )
    if device is None:
        return False
    device.last_used_at = now
    device.expires_at = now + timedelta(days=_trust_days())   # rolling expiration
    db.session.commit()
    log_auth_event(AuthEvent.TRUSTED_DEVICE_USED, user=user, ip=_client_ip(request), device=device.id)
    return True


# --- Register a trusted device (+ security notification) -----------------------

def remember_device(user, response, request) -> TrustedDevice:
    """Create a trusted-device record for ``user`` and attach the secure cookie
    to ``response``. Stores only the salted token/IP/user-agent hashes.

    The cookie is ``HttpOnly`` and ``SameSite=Lax``; it is marked ``Secure``
    whenever the app runs over HTTPS (mirrors ``SESSION_COOKIE_SECURE``). Sends a
    best-effort "new device" security notification email."""
    raw = secrets.token_urlsafe(32)
    now = utcnow()
    days = _trust_days()
    device = TrustedDevice(
        user_id=user.id,
        token_hash=_token_hash(raw),
        device_label=_device_label(request),
        ip_hash=_hash_value(_client_ip(request)),
        user_agent_hash=_hash_value(request.headers.get("User-Agent") if request else None),
        last_used_at=now,
        expires_at=now + timedelta(days=days),
    )
    db.session.add(device)
    db.session.commit()

    response.set_cookie(
        _cookie_name(),
        raw,
        max_age=days * 24 * 60 * 60,
        httponly=True,
        secure=bool(current_app.config.get("SESSION_COOKIE_SECURE", False)),
        samesite="Lax",
        path="/",
    )
    log_auth_event(AuthEvent.TRUSTED_DEVICE_CREATED, user=user, ip=_client_ip(request), device=device.id)
    _send_new_device_email(user, device)
    return device


def _send_new_device_email(user, device: TrustedDevice) -> None:
    """Best-effort security notification that a new device was trusted. Reuses
    the shared email sender (:func:`otp_service.send_email`) and the branded
    email layout — never raises into (and so never breaks) the login flow.
    Respects the user's "new device" notification preference."""
    try:
        from app.auth.security_service import notifications_enabled
        if not notifications_enabled(user, "new_device"):
            return
        try:
            manage_url = url_for("auth.trusted_devices_page", _external=True)
        except Exception:
            manage_url = None
        ctx = {
            "device_label": device.device_label or "New device",
            "when": device.created_at.strftime("%d %b %Y, %H:%M UTC") if device.created_at else "just now",
            "days": _trust_days(),
            "manage_url": manage_url,
            "support_email": current_app.config.get("SUPPORT_EMAIL", "support@studentbazaar.in"),
        }
        otp_service.send_email(
            user.email,
            "New device added to your Student Bazaar account",
            render_template("emails/new_device.txt", **ctx),
            render_template("emails/new_device.html", **ctx),
            label="new_device",
        )
    except Exception:  # pragma: no cover - notification must never break login
        current_app.logger.exception(
            "Failed to send new-device notification for user %s", getattr(user, "id", None)
        )


# --- Device management (Security → Trusted Devices) ----------------------------

def list_active_devices(user) -> list[TrustedDevice]:
    """Active (non-revoked, non-expired) trusted devices for ``user``, most
    recently used first — the data behind the management page."""
    now = utcnow()
    return db.session.scalars(
        db.select(TrustedDevice)
        .where(
            TrustedDevice.user_id == user.id,
            TrustedDevice.revoked_at.is_(None),
            TrustedDevice.expires_at > now,
        )
        .order_by(TrustedDevice.last_used_at.desc())
    ).all()


def current_device_id(user, request) -> int | None:
    """Id of the device matching the browser's current cookie (to flag "This
    device" in the list), or ``None``."""
    raw = request.cookies.get(_cookie_name()) if request else None
    if not raw:
        return None
    device = db.session.scalar(
        db.select(TrustedDevice)
        .where(
            TrustedDevice.user_id == user.id,
            TrustedDevice.token_hash == _token_hash(raw),
            TrustedDevice.revoked_at.is_(None),
        )
        .limit(1)
    )
    return device.id if device else None


def revoke_device(user, device_id, request, response) -> bool:
    """Revoke one trusted device the user owns. If it is the very device the
    request is coming from, the browser cookie is cleared too. Returns ``True``
    when a device was revoked. Audits ``trusted_device_revoked``."""
    device = db.session.get(TrustedDevice, device_id)
    if device is None or device.user_id != user.id or device.revoked_at is not None:
        return False
    device.revoked_at = utcnow()
    db.session.commit()

    raw = request.cookies.get(_cookie_name()) if request else None
    if raw and device.token_hash == _token_hash(raw):
        response.delete_cookie(_cookie_name(), path="/")
    log_auth_event(AuthEvent.TRUSTED_DEVICE_REVOKED, user=user, device=device.id)
    return True


def revoke_all_for_user(user, *, except_device_id: int | None = None,
                        event: str = AuthEvent.TRUSTED_DEVICE_REVOKED) -> int:
    """Revoke live trusted devices for ``user`` in one statement. Returns the
    number revoked; audits ``event`` (with the count) when any were. The caller
    clears the browser cookie where a response is available.

    ``except_device_id`` keeps one device trusted — used by "remove all other
    devices" (keep the current one). Also used by the "remove all" control and
    after a successful password reset (with
    ``event=TRUSTED_DEVICES_REVOKED_AFTER_PASSWORD_RESET``)."""
    now = utcnow()
    conditions = [TrustedDevice.user_id == user.id, TrustedDevice.revoked_at.is_(None)]
    if except_device_id is not None:
        conditions.append(TrustedDevice.id != except_device_id)
    result = db.session.execute(
        db.update(TrustedDevice).where(*conditions).values(revoked_at=now)
    )
    db.session.commit()
    count = result.rowcount or 0
    if count:
        log_auth_event(event, user=user, count=count)
    return count


def rename_device(user, device_id, name: str) -> bool:
    """Set a user-chosen friendly name on one of the user's trusted devices.
    Returns ``True`` when renamed. Audits ``trusted_device_renamed``."""
    device = db.session.get(TrustedDevice, device_id)
    if device is None or device.user_id != user.id or device.revoked_at is not None:
        return False
    device.name = (name or "").strip()[:80] or None
    db.session.commit()
    log_auth_event(AuthEvent.TRUSTED_DEVICE_RENAMED, user=user, device=device.id)
    return True


def clear_cookie(response) -> None:
    """Drop the trusted-device cookie from the browser (used by "remove all")."""
    response.delete_cookie(_cookie_name(), path="/")


# --- Housekeeping --------------------------------------------------------------

def cleanup_expired() -> int:
    """Delete trusted-device rows whose ``expires_at`` has passed (safe GC —
    expired rows can no longer authenticate anything). Returns the number removed.
    Exposed to operators via ``flask cleanup-trusted-devices`` and callable from
    a scheduled job."""
    result = db.session.execute(
        db.delete(TrustedDevice).where(TrustedDevice.expires_at < utcnow())
    )
    db.session.commit()
    count = result.rowcount or 0
    if count:
        current_app.logger.info("trusted_devices.cleanup removed=%s", count)
    return count
