"""§6.2 / v0.11.0: trust device for 30 days (roadmap item #9).

After a successful OTC or passcode sign-in, a contributor may check
"trust this device for 30 days." The framework then issues a
server-issued opaque token, hashes it (bcrypt) for storage in the
`device_trust` table, and sets a long-lived cookie carrying the raw
token. On a subsequent visit, the cookie is presented at
`/auth/device-trust/start`; if a non-expired, non-revoked row matches,
the session is re-established without another OTC / passcode round
trip.

The shape:

  * `issue(user_id, user_agent)` — mint a fresh CSPRNG token, hash it,
    insert a row, and return the raw token + row id so the endpoint
    can set the cookie. The 30-day expiry is the only knob; the
    `revoked_at` column stays NULL.
  * `lookup(raw_token)` — walk the user's active rows (the unique
    index keys on the hash, so we read a small candidate set), check
    the bcrypt hash in constant time, drop any row whose `expires_at`
    has passed or whose `revoked_at` is non-NULL, and return the
    matched row or None. On a hit, refresh `last_seen_at`.
  * `list_for_user(user_id)` — return the active rows for the
    /settings/devices surface. Revoked + expired rows are filtered out
    so the surface only shows live trust grants.
  * `revoke(user_id, row_id)` — stamp `revoked_at` on the row. The
    next lookup refuses the cookie token (the row is dead).
  * `revoke_all(user_id)` — bulk-revoke every active row for the user.
    The /settings/devices surface's "revoke all" button calls this.

Cookie shape: `rfc_device_trust`. HttpOnly, Secure, SameSite=Lax,
Max-Age=2592000 (30 days), Path=/. The cookie value is the raw token;
server-side storage is the hash. The cookie is "essential" per the
v0.13.0 cookie-consent banner (it is part of authentication, not
analytics), so the framework sets it regardless of analytics /
other-cookies choices.

Constant-time comparison: bcrypt's `checkpw` is already constant-time
over the hash bytes. We walk the candidate set linearly with `_check`
which delegates to `bcrypt.checkpw`; no early-exit shortcut leaks
which row was the match.

The raw token never appears in a log line or an exception message;
the helpers carry the token only as a parameter and forget it after
hashing.

The cookie sits orthogonal to the §6.1 `permission_state` gate: a
revoked or pending user with a valid device-trust cookie still
re-establishes their session (the cookie identifies the user, not
their admission state), and the existing `require_contributor` /
`require_admin` dependencies in `auth.py` continue to refuse the
unrelated write surfaces.
"""
from __future__ import annotations

import logging
import secrets
from dataclasses import dataclass

import bcrypt

from . import db
from .auth import SessionUser

log = logging.getLogger(__name__)


# ---------------------------------------------------------------------------
# Tunables — hard-coded in v0.11.0 (§19.2 candidate to env-ify later).
# ---------------------------------------------------------------------------

TRUST_DURATION_DAYS = 30
COOKIE_NAME = "rfc_device_trust"
COOKIE_MAX_AGE_SECONDS = TRUST_DURATION_DAYS * 24 * 60 * 60
# 256 bits of CSPRNG entropy. `secrets.token_urlsafe(32)` yields ~43
# URL-safe characters; the bcrypt hash is what's stored, so the raw
# token only ever lives in the cookie.
TOKEN_BYTES = 32
# User-Agent header values seen in the wild can be unbounded; clamp
# to a reasonable ceiling so a hostile UA doesn't bloat the row.
USER_AGENT_MAX_LENGTH = 1024


# ---------------------------------------------------------------------------
# Issue
# ---------------------------------------------------------------------------


@dataclass
class IssueOutcome:
    """The shape returned from `issue`.

    `raw_token` is the cookie value to send to the client; it never
    appears in storage. `row_id` is the surrogate key for the
    /settings/devices UI to address the row by id.
    """
    raw_token: str
    row_id: int

    @property
    def cookie_value(self) -> str:
        """The value to put in the `rfc_device_trust` cookie: the row-id
        selector joined to the raw token (v0.25.0 / audit 0026 M1). The
        selector lets `lookup` read one indexed row instead of scanning."""
        return f"{self.row_id}.{self.raw_token}"


def _new_token() -> str:
    return secrets.token_urlsafe(TOKEN_BYTES)


def _hash(token: str) -> str:
    return bcrypt.hashpw(token.encode("utf-8"), bcrypt.gensalt()).decode("ascii")


def _check(token: str, token_hash: str) -> bool:
    try:
        return bcrypt.checkpw(token.encode("utf-8"), token_hash.encode("ascii"))
    except (ValueError, TypeError):
        return False


def _trim_user_agent(ua: str) -> str:
    ua = (ua or "").strip()
    if len(ua) > USER_AGENT_MAX_LENGTH:
        return ua[:USER_AGENT_MAX_LENGTH]
    return ua


def issue(user_id: int, user_agent: str) -> IssueOutcome:
    """Mint a fresh device-trust token + row for `user_id`.

    The row's expiry is set 30 days in the future. The hash, not the
    raw token, lands in the database. The caller (the endpoint) sets
    the cookie with the raw token returned here.
    """
    raw = _new_token()
    h = _hash(raw)
    ua = _trim_user_agent(user_agent)
    cur = db.conn().execute(
        f"""
        INSERT INTO device_trust (user_id, device_token_hash, expires_at, user_agent)
        VALUES (?, ?, datetime('now', '+{TRUST_DURATION_DAYS} days'), ?)
        """,
        (user_id, h, ua),
    )
    row_id = cur.lastrowid
    return IssueOutcome(raw_token=raw, row_id=row_id)


# ---------------------------------------------------------------------------
# Lookup
# ---------------------------------------------------------------------------


@dataclass
class LookupOutcome:
    """The result of `lookup`.

    `user` is populated only on a hit. `reason` distinguishes the
    failure modes so the endpoint can decide whether to clear the
    cookie ('expired', 'revoked', 'unknown') or just refuse ('invalid').
    """
    ok: bool
    user: SessionUser | None
    reason: str  # 'ok' | 'invalid' | 'unknown' | 'expired' | 'revoked'
    row_id: int | None = None


def lookup(raw_token: str) -> LookupOutcome:
    """Resolve a presented cookie token to a user.

    A hit refreshes `last_seen_at` on the matched row. A miss returns
    a reason so the endpoint can clear the stale cookie if the row
    was revoked or expired (vs. simply unknown, which probably means
    the cookie was forged or the row was wiped by a /settings/devices
    revoke from another browser).
    """
    raw = (raw_token or "").strip()
    if not raw:
        return LookupOutcome(ok=False, user=None, reason="invalid")

    # v0.25.0 (audit 0026 M1): the cookie is "<row_id>.<raw_token>". We
    # parse the row-id selector and read exactly ONE row by its indexed
    # primary key, then bcrypt-check the token against that single row.
    #
    # The previous shape read EVERY device_trust row (all users, including
    # revoked/expired) and bcrypt-checked each — an unauthenticated
    # CPU-amplification DoS reachable at /auth/device-trust/start that
    # grew without bound as the table accumulated. bcrypt's per-row salt
    # is why we can't SELECT by hash; carrying the row-id in the cookie is
    # the standard fix (the id is not secret; the token still is).
    selector, sep, token = raw.partition(".")
    if not sep or not selector.isdigit() or not token:
        # Legacy bare-token cookies (pre-v0.25.0) and malformed values land
        # here. We refuse rather than fall back to a full-table scan, so
        # the amplification path is fully closed; affected users simply
        # re-authenticate once via OTC/passcode and get a new cookie.
        return LookupOutcome(ok=False, user=None, reason="invalid")

    matched = db.conn().execute(
        """
        SELECT id, user_id, device_token_hash, expires_at, revoked_at
          FROM device_trust
         WHERE id = ?
        """,
        (int(selector),),
    ).fetchone()

    # One bcrypt check, against the selected row only. A wrong/forged token
    # for a real id reads as 'unknown' (cookie cleared), same as a missing
    # row — a probing client can't distinguish the two.
    if matched is None or not _check(token, matched["device_token_hash"]):
        return LookupOutcome(ok=False, user=None, reason="unknown")

    if matched["revoked_at"] is not None:
        return LookupOutcome(ok=False, user=None, reason="revoked", row_id=matched["id"])

    expired = db.conn().execute(
        "SELECT datetime(?) < datetime('now') AS expired",
        (matched["expires_at"],),
    ).fetchone()["expired"]
    if expired:
        return LookupOutcome(ok=False, user=None, reason="expired", row_id=matched["id"])

    # Refresh last-seen so the /settings/devices surface can show the
    # user when each device was last active. This is the only write
    # the lookup path does on the hot read.
    db.conn().execute(
        "UPDATE device_trust SET last_seen_at = datetime('now') WHERE id = ?",
        (matched["id"],),
    )
    user_row = db.conn().execute(
        """
        SELECT id, gitea_id, gitea_login, email, display_name, avatar_url, role, permission_state
          FROM users
         WHERE id = ?
        """,
        (matched["user_id"],),
    ).fetchone()
    if user_row is None:
        # The user row was deleted but the device_trust row hadn't
        # cascaded yet (shouldn't happen under the FK ON DELETE
        # CASCADE — be defensive anyway). Treat as 'unknown' so the
        # endpoint clears the cookie.
        return LookupOutcome(ok=False, user=None, reason="unknown", row_id=matched["id"])

    # Also stamp last_seen_at on the user row so the user's overall
    # activity stamp keeps pace with cookie-only sign-ins.
    db.conn().execute(
        "UPDATE users SET last_seen_at = datetime('now') WHERE id = ?",
        (matched["user_id"],),
    )

    return LookupOutcome(
        ok=True,
        user=SessionUser(
            user_id=user_row["id"],
            gitea_id=user_row["gitea_id"] or 0,
            gitea_login=user_row["gitea_login"] or "",
            display_name=user_row["display_name"],
            email=user_row["email"] or "",
            avatar_url=user_row["avatar_url"] or "",
            role=user_row["role"],
            permission_state=user_row["permission_state"] or "granted",
        ),
        reason="ok",
        row_id=matched["id"],
    )


# ---------------------------------------------------------------------------
# List / revoke (for the /settings/devices surface)
# ---------------------------------------------------------------------------


@dataclass
class DeviceRow:
    """The shape the /settings/devices endpoint returns.

    Note the absence of `device_token_hash` — the hash is structurally
    private, and the surface has no use for it.
    """
    id: int
    created_at: str
    expires_at: str
    last_seen_at: str
    user_agent: str


def list_for_user(user_id: int) -> list[DeviceRow]:
    """Active device-trust rows for the user, freshest first.

    Filters out revoked rows and rows whose expiry has passed; the
    surface only shows live trust grants. A user wondering "which
    devices are signed in" gets the answer that matches what the
    framework would actually accept on a presented cookie.
    """
    rows = db.conn().execute(
        """
        SELECT id, created_at, expires_at, last_seen_at, user_agent
          FROM device_trust
         WHERE user_id = ?
           AND revoked_at IS NULL
           AND datetime(expires_at) > datetime('now')
         ORDER BY last_seen_at DESC, id DESC
        """,
        (user_id,),
    ).fetchall()
    return [
        DeviceRow(
            id=row["id"],
            created_at=row["created_at"],
            expires_at=row["expires_at"],
            last_seen_at=row["last_seen_at"],
            user_agent=row["user_agent"] or "",
        )
        for row in rows
    ]


def revoke(user_id: int, row_id: int) -> bool:
    """Revoke a single device-trust row for the given user.

    Returns True iff a row was matched (still active, belongs to the
    user). The user-id scope is enforced in SQL so a hostile client
    cannot revoke another user's row by guessing ids.
    """
    cur = db.conn().execute(
        """
        UPDATE device_trust
           SET revoked_at = datetime('now')
         WHERE id = ?
           AND user_id = ?
           AND revoked_at IS NULL
        """,
        (row_id, user_id),
    )
    return cur.rowcount > 0


def revoke_all(user_id: int) -> int:
    """Revoke every active device-trust row for the user. Returns the
    count of rows touched.

    The /settings/devices "revoke all" button calls this. The user's
    current request stays authenticated via its session cookie; the
    device-trust cookie on the current device is also revoked, but
    the session middleware's `rfc_session` cookie keeps the request
    flow alive until the user signs out or the session cookie
    expires.
    """
    cur = db.conn().execute(
        """
        UPDATE device_trust
           SET revoked_at = datetime('now')
         WHERE user_id = ?
           AND revoked_at IS NULL
        """,
        (user_id,),
    )
    return cur.rowcount
