"""A tenant's sign-in policy (Tenant.security_settings): two-factor
requirements, lockout after repeated failures, how long a session lasts,
and single sign-on through the tenant's own identity provider (OpenID
Connect — app/crud/oidc.py).

Stored as JSONB, read through SecuritySettings so every field has its
default. The IdP client secret is kept encrypted and never sent back out —
SecuritySettingsRead says only whether one is set.
"""

from datetime import datetime, timedelta, timezone
from typing import Literal

from fastapi import HTTPException
from pydantic import BaseModel, Field, HttpUrl, field_validator

from app.core.crypto import decrypt_secret, encrypt_secret
from app.models.identity import Tenant, User

MfaPolicy = Literal["optional", "internal", "everyone"]


class SsoSettings(BaseModel):
    enabled: bool = False
    # The provider's issuer URL — its discovery document is at
    # {issuer}/.well-known/openid-configuration.
    issuer: str = ""
    client_id: str = ""
    client_secret_encrypted: str | None = None
    button_label: str = "Sign in with SSO"
    # Email domains whose people may sign in this way ([] = any).
    allowed_domains: list[str] = []
    # Someone the provider vouches for who has no account here yet gets one
    # (an internal user with default_role_id) — else they're turned away.
    auto_create_users: bool = False
    default_role_id: str | None = None
    # Internal users must sign in through the provider (Portal users keep
    # their passwords). Switching it on needs a session that signed in by SSO.
    require_sso: bool = False


class SecuritySettings(BaseModel):
    # Who must use a second factor: no one (each user may turn it on),
    # internal users (Studio), or everyone (Portal users too).
    mfa_policy: MfaPolicy = "optional"
    # Failed passwords or codes in a row before the account is locked, and for how long.
    lockout_attempts: int = Field(5, ge=3, le=20)
    lockout_minutes: int = Field(15, ge=1, le=1440)
    # How long a session lasts (None: the deployment's JWT_EXPIRY_MINUTES).
    session_minutes: int | None = Field(None, ge=5, le=60 * 24 * 30)
    sso: SsoSettings = SsoSettings()


class SsoSettingsWrite(BaseModel):
    """What an administrator sends: the client secret only when changing it."""

    enabled: bool = False
    issuer: str = ""
    client_id: str = ""
    client_secret: str | None = None
    button_label: str = "Sign in with SSO"
    allowed_domains: list[str] = []
    auto_create_users: bool = False
    default_role_id: str | None = None
    require_sso: bool = False

    @field_validator("issuer")
    @classmethod
    def _issuer(cls, v: str) -> str:
        v = v.strip().rstrip("/")
        if v:
            HttpUrl(v)  # raises on anything that isn't an http(s) URL
            if not v.startswith("https://") and not v.startswith(("http://localhost", "http://127.0.0.1")):
                raise ValueError("The issuer must be an https:// URL")
        return v

    @field_validator("allowed_domains")
    @classmethod
    def _domains(cls, v: list[str]) -> list[str]:
        return sorted({d.strip().lower().lstrip("@") for d in v if d.strip()})


class SecuritySettingsWrite(BaseModel):
    mfa_policy: MfaPolicy = "optional"
    lockout_attempts: int = Field(5, ge=3, le=20)
    lockout_minutes: int = Field(15, ge=1, le=1440)
    session_minutes: int | None = Field(None, ge=5, le=60 * 24 * 30)
    sso: SsoSettingsWrite = SsoSettingsWrite()


class SsoSettingsRead(BaseModel):
    enabled: bool
    issuer: str
    client_id: str
    has_client_secret: bool
    button_label: str
    allowed_domains: list[str]
    auto_create_users: bool
    default_role_id: str | None
    require_sso: bool


class SecuritySettingsRead(BaseModel):
    mfa_policy: MfaPolicy
    lockout_attempts: int
    lockout_minutes: int
    session_minutes: int | None
    sso: SsoSettingsRead
    # Where the provider must send people back to — registered with it.
    sso_redirect_uri: str


def settings_of(tenant: Tenant) -> SecuritySettings:
    return SecuritySettings.model_validate(tenant.security_settings or {})


def to_read(s: SecuritySettings, redirect_uri: str) -> SecuritySettingsRead:
    sso = s.sso
    return SecuritySettingsRead(
        mfa_policy=s.mfa_policy, lockout_attempts=s.lockout_attempts, lockout_minutes=s.lockout_minutes, session_minutes=s.session_minutes,
        sso=SsoSettingsRead(
            enabled=sso.enabled, issuer=sso.issuer, client_id=sso.client_id, has_client_secret=bool(sso.client_secret_encrypted),
            button_label=sso.button_label, allowed_domains=sso.allowed_domains, auto_create_users=sso.auto_create_users,
            default_role_id=sso.default_role_id, require_sso=sso.require_sso,
        ),
        sso_redirect_uri=redirect_uri,
    )


def apply_write(current: SecuritySettings, payload: SecuritySettingsWrite, *, session_used_sso: bool) -> SecuritySettings:
    """The new settings, keeping the stored client secret unless a new one
    was sent. Refuses a combination that would lock people out."""
    w = payload.sso
    secret = current.sso.client_secret_encrypted
    if w.client_secret is not None:
        secret = encrypt_secret(w.client_secret) if w.client_secret.strip() else None
    if w.enabled and not (w.issuer and w.client_id and secret):
        raise HTTPException(422, "Single sign-on needs the provider's issuer URL, a client ID and a client secret.")
    if w.auto_create_users and not w.default_role_id:
        raise HTTPException(422, "Choose the role new SSO users get.")
    if w.require_sso and not w.enabled:
        raise HTTPException(422, "Requiring SSO needs single sign-on switched on.")
    if w.require_sso and not current.sso.require_sso and not session_used_sso:
        raise HTTPException(
            409, "Sign in with SSO yourself first, then require it — that proves it works before everyone else depends on it."
        )
    return SecuritySettings(
        mfa_policy=payload.mfa_policy, lockout_attempts=payload.lockout_attempts, lockout_minutes=payload.lockout_minutes,
        session_minutes=payload.session_minutes,
        sso=SsoSettings(**w.model_dump(exclude={"client_secret"}), client_secret_encrypted=secret),
    )


def client_secret(s: SecuritySettings) -> str:
    return decrypt_secret(s.sso.client_secret_encrypted) if s.sso.client_secret_encrypted else ""


# ---- applying the policy -----------------------------------------------------------------------------------


def mfa_required(s: SecuritySettings, user: User) -> bool:
    if s.mfa_policy == "everyone":
        return True
    return s.mfa_policy == "internal" and user.vendor_id is None


def password_allowed(s: SecuritySettings, user: User) -> bool:
    """Whether this person may sign in with a password at all."""
    return not (s.sso.enabled and s.sso.require_sso and user.vendor_id is None)


def session_expiry(s: SecuritySettings) -> timedelta | None:
    return timedelta(minutes=s.session_minutes) if s.session_minutes else None


def _now() -> datetime:
    return datetime.now(timezone.utc)


def locked_message(user: User) -> str | None:
    if user.locked_until and user.locked_until > _now():
        minutes = max(1, int((user.locked_until - _now()).total_seconds() // 60) + 1)
        return f"Too many failed attempts — this account is locked for {minutes} more minute{'s' if minutes != 1 else ''}."
    return None


def record_failure(s: SecuritySettings, user: User) -> None:
    """A wrong password or code: counted, and the account locked once there
    have been too many in a row."""
    user.failed_sign_ins = (user.failed_sign_ins or 0) + 1
    if user.failed_sign_ins >= s.lockout_attempts:
        user.locked_until = _now() + timedelta(minutes=s.lockout_minutes)
        user.failed_sign_ins = 0


def record_success(user: User) -> None:
    user.failed_sign_ins = 0
    user.locked_until = None
