"""A workspace's password policy (Tenant.password_policy), set by the Super
Admin per workspace (PUT /platform-admin/tenants/<id>/password-policy) and
checked wherever a password is set: registering, changing your own,
resetting by link, and an administrator (the workspace's or the Super
Admin) setting someone's — through set_password() below, the one way a
user's password changes.

Every rule is configurable, and a rule left at its default is off except the
length bounds (8 to 128 — what the platform always required):
  * length: min_length / max_length;
  * character mix: at least min_uppercase, min_lowercase, min_digits,
    min_special characters, and at least min_character_types of the four;
  * no spaces; no character repeated more than max_repeated times in a row;
  * not containing the person's name or the part of their email before @;
  * not one of the commonly used passwords (COMMON below);
  * matching a pattern of the workspace's own (custom_pattern + message);
  * not one of the last history_count passwords; not changed again within
    min_age_hours (an administrator's reset isn't held to that);
  * expires after expiry_days — the next sign-in asks for a new one — and,
    with change_after_admin_set, a password an administrator set must be
    changed at the next sign-in.

The browser shows the same rules as the person types (frontend
lib/passwordPolicy.ts); the server's check is the one that counts."""

import re
from datetime import datetime, timedelta, timezone

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

from app.core.auth import hash_password, verify_password
from app.models.identity import Tenant, User

# What counts as a "special" character: anything that isn't a letter, a digit or a space.
_SPECIAL = re.compile(r"[^\w\s]|_")

# A short list of the passwords most often found in breaches — checked
# without case, and with digits/symbols stripped from the end ("Password1!").
COMMON = frozenset(
    """
    password passw0rd p@ssword p@ssw0rd password1 password123 123456 1234567 12345678 123456789 1234567890 12345 1234 111111
    000000 123123 654321 666666 7777777 888888 112233 121212 123321 qwerty qwerty123 qwertyuiop asdfgh asdfghjkl zxcvbnm
    1q2w3e4r 1qaz2wsx qazwsx abc123 abcd1234 abcdef letmein welcome welcome1 admin admin123 administrator root toor login
    master monkey dragon football baseball iloveyou sunshine princess shadow superman batman trustno1 starwars whatever
    hello freedom secret changeme default guest test test123 user india india123 kerala chennai mumbai delhi computer
    internet samsung google iphone apple charlie michael jennifer jordan hunter killer pass pass123 temp temp123 summer
    winter spring autumn flower cricket
    """.split()
)


class PasswordPolicy(BaseModel):
    min_length: int = Field(8, ge=4, le=128)
    max_length: int = Field(128, ge=8, le=256)
    min_uppercase: int = Field(0, ge=0, le=20)
    min_lowercase: int = Field(0, ge=0, le=20)
    min_digits: int = Field(0, ge=0, le=20)
    min_special: int = Field(0, ge=0, le=20)
    # Of upper case, lower case, digits and symbols — how many kinds (0 = no rule).
    min_character_types: int = Field(0, ge=0, le=4)
    disallow_spaces: bool = False
    # The same character more than this many times in a row ("aaaa") — 0 = no rule.
    max_repeated: int = Field(0, ge=0, le=10)
    disallow_personal_info: bool = False
    disallow_common: bool = False
    # A regular expression the whole password must match, and what to say when it doesn't.
    custom_pattern: str | None = Field(default=None, max_length=500)
    custom_message: str | None = Field(default=None, max_length=300)
    # Can't reuse any of the last N passwords (0 = no rule).
    history_count: int = Field(0, ge=0, le=24)
    # A password must be this old before it can be changed again (0 = no rule).
    min_age_hours: int = Field(0, ge=0, le=24 * 30)
    # A password expires after this many days (0 = never).
    expiry_days: int = Field(0, ge=0, le=730)
    # A password an administrator set must be changed at the next sign-in.
    change_after_admin_set: bool = False

    @field_validator("custom_pattern")
    @classmethod
    def _pattern(cls, v: str | None) -> str | None:
        v = (v or "").strip() or None
        if v:
            try:
                re.compile(v)
            except re.error as exc:
                raise ValueError(f"the pattern isn't a valid regular expression: {exc}") from None
        return v

    @model_validator(mode="after")
    def _bounds(self) -> "PasswordPolicy":
        if self.max_length < self.min_length:
            raise ValueError("The longest allowed password can't be shorter than the shortest")
        needed = self.min_uppercase + self.min_lowercase + self.min_digits + self.min_special
        if needed > self.max_length:
            raise ValueError("The characters required add up to more than the longest allowed password")
        return self


class PasswordPolicyRead(PasswordPolicy):
    # The rules in words, for the sign-up, reset and change-password pages.
    requirements: list[str] = []


def policy_of(tenant: Tenant | None) -> PasswordPolicy:
    try:
        return PasswordPolicy.model_validate((tenant.password_policy if tenant else None) or {})
    except ValueError:
        return PasswordPolicy()


def _plural(n: int, word: str) -> str:
    return f"{n} {word}{'' if n == 1 else 's'}"


def requirements(p: PasswordPolicy) -> list[str]:
    """The policy in words, one rule each."""
    out = [f"{p.min_length} to {p.max_length} characters long"]
    for n, word in ((p.min_uppercase, "upper case letter"), (p.min_lowercase, "lower case letter"), (p.min_digits, "digit"), (p.min_special, "symbol")):
        if n:
            out.append(f"at least {_plural(n, word)}")
    if p.min_character_types:
        out.append(f"at least {p.min_character_types} of: upper case letters, lower case letters, digits, symbols")
    if p.disallow_spaces:
        out.append("no spaces")
    if p.max_repeated:
        out.append(f"no character more than {p.max_repeated} time{'s' if p.max_repeated != 1 else ''} in a row")
    if p.disallow_personal_info:
        out.append("not containing your name or email address")
    if p.disallow_common:
        out.append("not a commonly used password")
    if p.custom_pattern:
        out.append(p.custom_message or "in the format your workspace requires")
    if p.history_count:
        out.append(f"not one of your last {_plural(p.history_count, 'password')}")
    return out


def _personal_parts(name: str | None, email: str | None) -> set[str]:
    parts = {w.lower() for w in re.split(r"[\s._@+-]+", (name or "")) if len(w) >= 3}
    local = (email or "").split("@", 1)[0].lower()
    if len(local) >= 3:
        parts.add(local)
        parts.update(w for w in re.split(r"[._+-]+", local) if len(w) >= 3)
    return parts


def problems(p: PasswordPolicy, password: str, *, name: str | None = None, email: str | None = None) -> list[str]:
    """What's wrong with `password` under the policy, one sentence each
    (history and age are checked by set_password, which knows the user)."""
    out: list[str] = []
    if len(password) < p.min_length:
        out.append(f"Use at least {_plural(p.min_length, 'character')}.")
    if len(password) > p.max_length:
        out.append(f"Use at most {_plural(p.max_length, 'character')}.")
    upper = sum(c.isupper() for c in password)
    lower = sum(c.islower() for c in password)
    digits = sum(c.isdigit() for c in password)
    special = len(_SPECIAL.findall(password))
    for have, need, word in ((upper, p.min_uppercase, "upper case letter"), (lower, p.min_lowercase, "lower case letter"), (digits, p.min_digits, "digit"), (special, p.min_special, "symbol")):
        if have < need:
            out.append(f"Include at least {_plural(need, word)}.")
    if p.min_character_types and sum(bool(n) for n in (upper, lower, digits, special)) < p.min_character_types:
        out.append(f"Use at least {p.min_character_types} kinds of character: upper case letters, lower case letters, digits, symbols.")
    if p.disallow_spaces and any(c.isspace() for c in password):
        out.append("Don't use spaces.")
    if p.max_repeated and re.search(r"(.)\1{%d,}" % p.max_repeated, password):
        out.append(f"Don't repeat a character more than {p.max_repeated} time{'s' if p.max_repeated != 1 else ''} in a row.")
    if p.disallow_personal_info:
        lowered = password.lower()
        if any(part in lowered for part in _personal_parts(name, email)):
            out.append("Don't use your name or email address in it.")
    if p.disallow_common:
        stem = re.sub(r"[\d\W_]+$", "", password.lower())
        if password.lower() in COMMON or stem in COMMON:
            out.append("That's a commonly used password — choose something harder to guess.")
    if p.custom_pattern:
        try:
            if re.fullmatch(p.custom_pattern, password) is None:
                out.append(p.custom_message or "It isn't in the format your workspace requires.")
        except re.error:
            pass
    return out


def _refuse(issues: list[str]) -> HTTPException:
    return HTTPException(
        status_code=422,
        detail={"code": "password_policy", "message": "The password doesn't meet this workspace's rules: " + " ".join(issues), "problems": issues},
    )


def check(tenant: Tenant | None, password: str, *, name: str | None = None, email: str | None = None) -> None:
    """HTTPException 422 (code "password_policy") when `password` breaks the
    workspace's rules — for a new account, before there's a user."""
    issues = problems(policy_of(tenant), password, name=name, email=email)
    if issues:
        raise _refuse(issues)


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


def set_password(tenant: Tenant | None, user: User, password: str, *, by_admin: bool = False) -> None:
    """Checks `password` against the workspace's rules (and, for the person
    themselves, their history and the minimum age), then sets it — keeping
    the history and when it changed. An administrator setting it may make
    the person change it at their next sign-in."""
    p = policy_of(tenant)
    issues = problems(p, password, name=user.name, email=user.email)
    if p.history_count and user.password_hash:
        earlier = [user.password_hash, *(user.password_history or [])][: p.history_count]
        if any(verify_password(password, h) for h in earlier if h):
            issues.append(f"Choose a password you haven't used for your last {_plural(p.history_count, 'password')}.")
    if not by_admin and p.min_age_hours and user.password_changed_at and not user.password_must_change:
        ready = user.password_changed_at + timedelta(hours=p.min_age_hours)
        if _now() < ready:
            issues.append(f"Your password was changed recently — it can be changed again after {ready.strftime('%d %b %Y, %H:%M')} UTC.")
    if issues:
        raise _refuse(issues)
    # The passwords before this one, newest first — as many as the rule looks back.
    user.password_history = [user.password_hash, *(user.password_history or [])][: p.history_count] if user.password_hash else list(user.password_history or [])[: p.history_count]
    user.password_hash = hash_password(password)
    user.password_changed_at = _now()
    user.password_must_change = bool(by_admin and p.change_after_admin_set)


def change_needed(tenant: Tenant | None, user: User) -> str | None:
    """Why this person must choose a new password before signing in — an
    administrator set it, or it's older than the workspace allows — or None."""
    if not user.password_hash:
        return None
    if user.password_must_change:
        return "Your password was set by an administrator — choose your own to continue."
    p = policy_of(tenant)
    if p.expiry_days and user.password_changed_at and _now() - user.password_changed_at > timedelta(days=p.expiry_days):
        return f"Your password is more than {_plural(p.expiry_days, 'day')} old — choose a new one to continue."
    return None
