"""BW identity -> app account, and the invite-only admission decision.

This module is the single place where "a verified BW identity" becomes "an
account in this app", and the single place that decides whether that account is
admitted. Routers never make either decision themselves — that is what keeps
admission auditable and keeps a future change (self-serve signup, an admin
invite screen) a one-file change.

Two separate things happen here, and conflating them is the mistake this file
exists to prevent:

  1. **Materialize** — upsert a `users` row from the trusted identity. Everyone
     who completes a BW sign-in gets a row. A row is not access.
  2. **Admit** — look for an invite. No invite means `role='none'`: signed in,
     recognized, and able to reach nothing but the "not invited" screen.

Roles are the APP's decision (plan D4). BW never assigns one; `report_role` only
mirrors the decision to the BW hub for display and grants nothing.
"""

from __future__ import annotations

import logging
from datetime import UTC, datetime

from sqlalchemy import func, select
from sqlalchemy.orm import Session

from .. import bw_auth
from ..models.identity import Invite, User
from .roles import ASSIGNABLE_ROLES, ROLE_NONE, ROLE_USER, label_for

log = logging.getLogger(__name__)


def _normalize_email(value: str | None) -> str:
    return (value or "").strip().lower()


def find_invite(db: Session, email: str) -> Invite | None:
    """The invite for this email, if any. Case-insensitive: invites are stored
    lower-cased, but a hand-written row could slip through with capitals, and a
    missed invite reads to the user as a broken login."""
    email = _normalize_email(email)
    if not email:
        return None
    return db.scalar(select(Invite).where(func.lower(Invite.email) == email))


def establish_user(db: Session, identity: dict) -> User:
    """Upsert the app account from a trusted BW identity and return the row.

    `identity` is either verified id_token claims or an /app/userinfo response —
    the same field shape. Caller commits.

    COLUMN-SCOPED: on an existing user only the BW-derived mirror fields
    (display_name, email, last_login_at) are refreshed. `role` and any app-owned
    column added later are NEVER written here, so re-login cannot clobber them.
    Do not rewrite this as a whole-row replace.
    """
    username = (identity.get("username") or "").strip().lower()
    if not username:
        raise ValueError("BW identity missing username")

    email = _normalize_email(identity.get("email"))
    first = (identity.get("first") or "").strip()
    last = (identity.get("last") or "").strip()
    display = f"{first} {last}".strip() or username
    now = datetime.now(UTC)

    user = db.scalar(select(User).where(User.external_username == username))
    if user is None:
        user = User(
            external_username=username,
            email=email,
            display_name=display,
            role=ROLE_NONE,  # default-deny; _apply_invite may upgrade it below
            last_login_at=now,
        )
        db.add(user)
        db.flush()
    else:
        # BW-derived mirror fields only. role is app-owned and untouched.
        user.display_name = display or user.display_name
        user.email = email or user.email
        user.last_login_at = now

    _apply_invite(db, user, now)
    return user


def _apply_invite(db: Session, user: User, now: datetime) -> None:
    """Seed the role from the invite allowlist — ONLY while the user has no real
    role yet.

    Seed-if-empty, never overwrite: once an account holds an admitted role, that
    value is the app's and survives every subsequent login. So a role changed in
    the database (or later, in an admin screen) is not silently reverted to
    whatever the original invite said.

    A user with no invite keeps `role='none'` and is not admitted. Revoking
    access is therefore a role change, not an invite deletion — deleting the
    invite row alone will NOT lock an already-admitted user out.
    """
    if user.role and user.role != ROLE_NONE:
        return  # already admitted; the app owns the role from here

    invite = find_invite(db, user.email)
    if invite is None:
        user.role = ROLE_NONE
        return

    granted = invite.role if invite.role in ASSIGNABLE_ROLES else ROLE_USER
    user.role = granted
    if invite.redeemed_at is None:
        invite.redeemed_at = now  # audit record; the invite row is kept


def report_role(username: str, role: str) -> None:
    """Mirror the app's role to the BW hub for display (best-effort).

    Cosmetic only — it grants nothing anywhere. A failure here must never fail a
    login, so every error is swallowed; a missing BW config in tests/dev
    included.
    """
    try:
        bw_auth.report_role(username, label_for(role))
    except Exception as exc:  # noqa: BLE001 — cosmetics must never break login
        log.warning("report_role failed for %s: %s", username, exc)
