"""
Registration and login API — passwords hashed with Argon2id; JWT access tokens (HS256).
Requires PostgreSQL (users + user_roles tables).

New accounts verify email with a **6-digit OTP** (email via SMTP or ``AUTH_MAIL_LOG_ONLY``); see ``/auth/verify-otp``,
``/auth/resend-otp``, migrations ``013_email_verification.sql`` + ``014_registration_otp.sql``, and optional env ``REGISTER_OTP_TTL_MINUTES`` (default **1**).
Legacy ``/auth/verify-email`` (magic link) remains for old tokens only.

Registration requires a complete staff profile (same rules as profile verification submit):
employee id, academic title (+ detail when «other»), unit (catalog UUID and/or freetext name), job title.

Server-derived roles:
  - Emails in AUTH_ADMIN_EMAILS (comma-separated env) get role ``admin`` with
    ``user_roles.admin_from_email_policy = TRUE`` (reconciled on register/login/refresh/me/profile).
  - When AUTH_ADMIN_EMAILS is unset, a built-in UMP allow-list applies (institution defaults).
  - All other allowed institutional emails get ``viewer`` on register (Người nộp đơn).
  - Client-supplied ``role`` on register is ignored (privilege escalation fix).
"""

from __future__ import annotations

import hashlib
import hmac
import os
import re
import secrets
import uuid
from datetime import datetime, timedelta, timezone
from typing import Any, Literal

import jwt
from argon2 import PasswordHasher

try:
    from argon2.exceptions import InvalidHashError, VerifyMismatchError
except ImportError:  # some envs ship argon2 bindings without InvalidHashError
    from argon2.exceptions import VerifyMismatchError

    class InvalidHashError(Exception):  # noqa: N818 — mirror argon2-cffi
        pass

from fastapi import APIRouter, Header, HTTPException, Request, Response
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
from sqlalchemy import delete, select
from sqlalchemy.exc import IntegrityError, ProgrammingError

from src.auth_jwt import (
    decode_access_token_user_id,
    decode_bearer_token,
    jwt_credential_version_from_payload,
    jwt_secret as jwt_secret_key,
)
from src.auth_mail import (
    deliver_email_verification_email,
    deliver_password_reset_email,
    deliver_registration_otp_email,
    mail_delivery_configured,
)
from src.auth_rate_limit import (
    allow_forgot_password,
    allow_login,
    allow_resend_registration_otp,
    allow_resend_verification,
    allow_reset_password,
)
from src.staff_profile_domain import (
    apply_reverify_from_verified,
    assert_complete_for_submission,
    assert_employee_id_shape,
    assert_unit_exclusive,
    material_staff_fields_changed,
    normalize_employee_id,
    staff_row_for_audit,
)
from src.audit import (
    AuditAction,
    jwt_payload_actor_email,
    persist_audit_standalone,
    record_audit,
    resolve_actor_fields,
)
from src.initiative_db.engine import get_session, is_postgres_enabled
from src.initiative_db.models import (
    AcademicTitle,
    EmailVerificationToken,
    PasswordResetToken,
    RegistrationOtpCode,
    Unit,
    User,
    UserRoleRow,
    UserStaffProfile,
)

router = APIRouter(prefix="/auth", tags=["auth"])

_pwd = PasswordHasher()

MAX_PASSWORD_INPUT_CHARS = 512

RESET_TOKEN_TTL = timedelta(hours=1)
VERIFY_EMAIL_TOKEN_TTL = timedelta(hours=48)
_register_otp_minutes_raw = os.getenv("REGISTER_OTP_TTL_MINUTES", "1").strip()
try:
    _REGISTER_OTP_MINUTES = int(_register_otp_minutes_raw or "1")
except ValueError:
    _REGISTER_OTP_MINUTES = 1
REGISTER_OTP_TTL = timedelta(minutes=max(1, min(_REGISTER_OTP_MINUTES, 24 * 60)))
REGISTER_OTP_MAX_FAILED_ATTEMPTS = 5
_OTP_VERIFY_REJECT_DETAIL = "Mã OTP không đúng hoặc đã hết hạn."

FORGOT_PASSWORD_RESPONSE: dict[str, str] = {
    "message": "Nếu email tồn tại trong hệ thống, hướng dẫn đặt lại mật khẩu đã được gửi.",
}

RESEND_VERIFICATION_RESPONSE: dict[str, str] = {
    "message": (
        "Nếu tài khoản cần xác minh, mã OTP đã được gửi đến email (kiểm tra hộp thư/spam)."
    ),
}

RESEND_OTP_RESPONSE: dict[str, str] = {
    "message": "Nếu tài khoản cần xác minh, mã OTP đã được gửi đến email (kiểm tra hộp thư/spam).",
}

VERIFY_EMAIL_SUCCESS: dict[str, str] = {
    "message": "Email đã xác minh. Bạn có thể đăng nhập.",
}

PASSWORD_RESET_SUCCESS: dict[str, str] = {
    "message": "Mật khẩu đã được cập nhật. Vui lòng đăng nhập bằng mật khẩu mới.",
}

# UMP or UMC faculty email (authoritative allow-list for *domain*; role allow-list is separate).
INSTITUTIONAL_EMAIL_RE = re.compile(
    r"^[a-zA-Z0-9._%+-]+@(ump|umc)\.edu\.vn\Z", re.IGNORECASE
)

# Default policy admins when AUTH_ADMIN_EMAILS is not set (must stay in sync with migration 007 cleanup list).
_DEFAULT_POLICY_ADMIN_EMAILS: frozenset[str] = frozenset(
    {
        "thaontt@ump.edu.vn",
        "nltanh@ump.edu.vn",
        "ldbaochau@ump.edu.vn",
        "htchuong@ump.edu.vn",
        "ththinh@ump.edu.vn",
        "lhthinh@ump.edu.vn",
    }
)

ROLE_LITERAL = Literal["admin", "editor", "viewer"]


def _policy_admin_emails() -> frozenset[str]:
    """
    Emails that receive ``admin`` from institutional policy (not self-service).

    If AUTH_ADMIN_EMAILS is set, **only** that comma-separated list is used (lowercased).
    If unset, the built-in UMP allow-list above applies.
    """
    raw = os.getenv("AUTH_ADMIN_EMAILS", "").strip()
    if raw:
        return frozenset(part.strip().lower() for part in raw.split(",") if part.strip())
    return _DEFAULT_POLICY_ADMIN_EMAILS


def _hash_password(plain: str) -> str:
    return _pwd.hash(plain)


def _verify_password(plain: str, hashed: str) -> bool:
    try:
        _pwd.verify(hashed, plain)
        return True
    except (VerifyMismatchError, InvalidHashError):
        return False


def _assert_password_policy(password: str) -> None:
    if len(password) < 6:
        raise HTTPException(status_code=400, detail="Mật khẩu tối thiểu 6 ký tự.")
    if len(password) > MAX_PASSWORD_INPUT_CHARS:
        raise HTTPException(status_code=400, detail="Mật khẩu quá dài.")
    if not re.search(r"[a-z]", password):
        raise HTTPException(status_code=400, detail="Mật khẩu phải có ít nhất một chữ cái thường.")
    if not re.search(r"[A-Z]", password):
        raise HTTPException(status_code=400, detail="Mật khẩu phải có ít nhất một chữ cái hoa.")
    if not re.search(r"\d", password):
        raise HTTPException(status_code=400, detail="Mật khẩu phải có ít nhất một chữ số.")
    if not re.search(r"[^A-Za-z0-9]", password):
        raise HTTPException(
            status_code=400,
            detail="Mật khẩu phải có ít nhất một ký tự đặc biệt (không chỉ chữ và số).",
        )


def _normalize_institutional_email(email: str) -> str:
    e = email.strip().lower()
    if not INSTITUTIONAL_EMAIL_RE.match(e):
        raise HTTPException(
            status_code=400,
            detail="Email phải là địa chỉ UMP hoặc UMC hợp lệ (dạng ten@ump.edu.vn hoặc ten@umc.edu.vn).",
        )
    return e


def _hash_reset_token(raw: str) -> str:
    return hashlib.sha256(raw.encode("utf-8")).hexdigest()


def _otp_ttl_label_vi() -> str:
    """Human-readable validity window for API copy (matches REGISTER_OTP_TTL)."""
    total = max(1, int(REGISTER_OTP_TTL.total_seconds()))
    if total >= 3600:
        return f"{total // 3600} giờ"
    if total >= 60:
        m = max(1, total // 60)
        return "1 phút" if m == 1 else f"{m} phút"
    return f"{total} giây"


def _register_success_otp_message() -> str:
    label = _otp_ttl_label_vi()
    return (
        "Đăng ký thành công. Mã OTP 6 số đã được gửi đến email UMP/UMC "
        "(kiểm tra cả thư mục spam). "
        f"Mã có hiệu lực trong {label}. Nhập mã trên trang đăng ký để kích hoạt tài khoản."
    )


def _maybe_raise_otp_table_missing(exc: ProgrammingError) -> None:
    blob = str(exc).lower()
    if "registration_otp_codes" not in blob:
        return
    if "does not exist" not in blob and "undefinedtable" not in blob.replace(" ", ""):
        return
    raise HTTPException(
        status_code=503,
        detail=(
            "Cơ sở dữ liệu chưa có bảng mã OTP (registration_otp_codes). "
            "Khởi động lại API để áp dụng migration, hoặc chạy be0/migrations/014_registration_otp.sql."
        ),
    ) from exc


async def _delete_pending_registration_otps(session: Any, user_id: uuid.UUID) -> None:
    try:
        await session.execute(
            delete(RegistrationOtpCode).where(
                RegistrationOtpCode.user_id == user_id,
                RegistrationOtpCode.used_at.is_(None),
            )
        )
    except ProgrammingError as e:
        _maybe_raise_otp_table_missing(e)
        raise


async def _issue_registration_otp(session: Any, user_id: uuid.UUID) -> str:
    try:
        await _delete_pending_registration_otps(session, user_id)
        plaintext = f"{secrets.randbelow(10**6):06d}"
        otp_hash = _hash_reset_token(plaintext)
        session.add(
            RegistrationOtpCode(
                user_id=user_id,
                otp_hash=otp_hash,
                expires_at=datetime.now(timezone.utc) + REGISTER_OTP_TTL,
                failed_attempts=0,
            )
        )
        await session.flush()
        return plaintext
    except ProgrammingError as e:
        _maybe_raise_otp_table_missing(e)
        raise


def _client_ip(request: Request) -> str:
    xf = request.headers.get("x-forwarded-for")
    if xf:
        part = xf.split(",")[0].strip()
        if part:
            return part
    if request.client and request.client.host:
        return request.client.host
    return "unknown"


def _issue_token(user_id: uuid.UUID, email: str, roles: list[str], credential_version: int) -> str:
    now = datetime.now(timezone.utc)
    exp = now + timedelta(hours=int(os.getenv("JWT_EXPIRE_HOURS", "12")))
    payload: dict[str, Any] = {
        "sub": str(user_id),
        "email": email,
        "roles": roles,
        "cv": int(credential_version),
        "iat": int(now.timestamp()),
        "exp": int(exp.timestamp()),
    }
    return jwt.encode(payload, jwt_secret_key(), algorithm="HS256")


async def _load_roles(session: Any, user_id: uuid.UUID) -> list[str]:
    stmt = select(UserRoleRow.role).where(UserRoleRow.user_id == user_id)
    rows = (await session.execute(stmt)).scalars().all()
    out = [str(r) for r in rows]
    return sorted(set(out))


async def _reconcile_policy_admin(session: Any, user: User) -> None:
    """
    Mandatory policy sync for ``admin`` tied to AUTH_ADMIN_EMAILS / defaults.

    - Allow-listed email: ensure ``admin`` row with admin_from_email_policy=TRUE.
    - Not allow-listed: delete ``admin`` row only when admin_from_email_policy is TRUE,
      preserving exceptional manual admin rows (FALSE).
    """
    email_norm = user.email.strip().lower()
    policy = _policy_admin_emails()

    stmt = select(UserRoleRow).where(
        UserRoleRow.user_id == user.id,
        UserRoleRow.role == "admin",
    )
    admin_row = (await session.execute(stmt)).scalar_one_or_none()

    if email_norm in policy:
        if admin_row is None:
            session.add(
                UserRoleRow(
                    user_id=user.id,
                    role="admin",
                    admin_from_email_policy=True,
                )
            )
        else:
            admin_row.admin_from_email_policy = True
    elif admin_row is not None and admin_row.admin_from_email_policy:
        await session.delete(admin_row)

    await session.flush()


async def _roles_after_reconcile(session: Any, user: User) -> list[str]:
    await _reconcile_policy_admin(session, user)
    return await _load_roles(session, user.id)


async def _load_staff_profile(session: Any, user_id: uuid.UUID) -> UserStaffProfile:
    sp = await session.get(UserStaffProfile, user_id)
    if sp is None:
        sp = UserStaffProfile(user_id=user_id)
        session.add(sp)
        await session.flush()
    return sp


async def _assert_academic_title_active(session: Any, code: str | None) -> None:
    if not code:
        return
    row = await session.get(AcademicTitle, code)
    if row is None or not row.active:
        raise HTTPException(status_code=400, detail="Học hàm / học vị không hợp lệ.")


async def _assert_unit_exists(session: Any, unit_id: uuid.UUID | None) -> None:
    if unit_id is None:
        return
    if await session.get(Unit, unit_id) is None:
        raise HTTPException(status_code=400, detail="Đơn vị không tồn tại trong danh mục.")


def _staff_profile_api_dict(user: User, sp: UserStaffProfile) -> dict[str, Any]:
    return {
        "employeeId": sp.employee_id,
        "academicTitleCode": sp.academic_title_code,
        "academicTitleOther": sp.academic_title_other,
        "unitId": str(user.unit_id) if user.unit_id else None,
        "unitNameFreetext": sp.unit_name_freetext,
        "jobTitle": sp.job_title,
        "profileVerificationStatus": sp.profile_verification_status,
        "verificationSubmittedAt": sp.verification_submitted_at,
        "verifiedAt": sp.verified_at,
        "verifiedByUserId": str(sp.verified_by_user_id) if sp.verified_by_user_id else None,
        "rejectionReason": sp.rejection_reason,
        "version": sp.version,
    }


def _user_public_dict(user: User, roles: list[str], sp: UserStaffProfile | None = None) -> dict[str, Any]:
    out: dict[str, Any] = {
        "id": str(user.id),
        "email": user.email,
        "name": user.full_name,
        "roles": roles,
        "phone": user.phone,
        "emailVerified": bool(getattr(user, "email_verified", True)),
    }
    if sp is not None:
        out["staffProfile"] = _staff_profile_api_dict(user, sp)
    return out


class RegisterBody(BaseModel):
    model_config = ConfigDict(extra="ignore")

    fullName: str = Field(..., min_length=2, max_length=200)
    email: str = Field(..., min_length=5, max_length=254)
    password: str = Field(..., min_length=1)
    passwordConfirm: str = Field(..., min_length=1)
    # Deprecated: ignored; server derives roles (admin vs viewer) from email policy.
    role: ROLE_LITERAL | None = Field(default=None, description="Ignored.")
    employeeId: str = Field(..., min_length=1, max_length=40)
    academicTitleCode: str = Field(..., min_length=1, max_length=64)
    academicTitleOther: str | None = Field(default=None, max_length=200)
    unitId: uuid.UUID | None = None
    unitNameFreetext: str | None = Field(default=None, max_length=300)
    jobTitle: str = Field(..., min_length=1, max_length=120)

    @field_validator("fullName")
    @classmethod
    def strip_name(cls, v: str) -> str:
        s = v.strip()
        if len(s) < 2:
            raise ValueError("Họ tên quá ngắn.")
        return s

    @field_validator("academicTitleCode", mode="before")
    @classmethod
    def strip_title_code(cls, v: object) -> str:
        if v is None or v == "":
            raise ValueError("Chọn học hàm / học vị.")
        s = str(v).strip()
        if not s:
            raise ValueError("Chọn học hàm / học vị.")
        return s

    @field_validator("academicTitleOther", "unitNameFreetext", mode="before")
    @classmethod
    def strip_optional_text(cls, v: object) -> str | None:
        if v is None or v == "":
            return None
        s = str(v).strip()
        return s or None

    @field_validator("employeeId", mode="before")
    @classmethod
    def strip_employee_required(cls, v: object) -> str:
        if v is None or v == "":
            raise ValueError("Vui lòng nhập mã số nhân sự.")
        s = str(v).strip()
        if not s:
            raise ValueError("Vui lòng nhập mã số nhân sự.")
        return s

    @field_validator("jobTitle", mode="before")
    @classmethod
    def strip_job_required(cls, v: object) -> str:
        if v is None or v == "":
            raise ValueError("Nhập chức vụ công tác.")
        s = str(v).strip()
        if not s:
            raise ValueError("Nhập chức vụ công tác.")
        return s

    @model_validator(mode="after")
    def staff_cross_field(self) -> RegisterBody:
        if self.academicTitleCode == "other":
            if not self.academicTitleOther or not str(self.academicTitleOther).strip():
                raise ValueError(
                    "Khi chọn «Khác», vui lòng nhập nội dung học hàm / học vị."
                )
        has_unit = self.unitId is not None or (
            self.unitNameFreetext is not None and len(str(self.unitNameFreetext).strip()) > 0
        )
        if not has_unit:
            raise ValueError("Chọn đơn vị công tác hoặc nhập tên đơn vị.")
        return self


class LoginBody(BaseModel):
    email: str = Field(..., max_length=254)
    password: str = Field(..., min_length=1, max_length=MAX_PASSWORD_INPUT_CHARS)

    @field_validator("email")
    @classmethod
    def strip_login_email(cls, v: str) -> str:
        s = v.strip()
        if not s:
            raise ValueError("Vui lòng nhập email.")
        return s

    @field_validator("password")
    @classmethod
    def reject_blank_password(cls, v: str) -> str:
        if not v.strip():
            raise ValueError("Vui lòng nhập mật khẩu.")
        return v


class ForgotPasswordBody(BaseModel):
    model_config = ConfigDict(extra="ignore")

    email: str = Field(..., min_length=5, max_length=254)


class ResetPasswordBody(BaseModel):
    model_config = ConfigDict(extra="ignore")

    token: str = Field(..., min_length=10, max_length=512)
    newPassword: str = Field(..., min_length=1)
    newPasswordConfirm: str = Field(..., min_length=1)


@router.post("/register")
async def register(body: RegisterBody) -> dict[str, Any]:
    if not is_postgres_enabled():
        raise HTTPException(
            status_code=503,
            detail="Đăng ký tạm thời không khả dụng (cơ sở dữ liệu chưa cấu hình).",
        )

    if body.password != body.passwordConfirm:
        raise HTTPException(status_code=400, detail="Mật khẩu xác nhận không khớp.")

    _assert_password_policy(body.password)
    email_n = _normalize_institutional_email(body.email)
    policy = _policy_admin_emails()

    pwd_hash = _hash_password(body.password)

    mail_out: tuple[str, str] | None = None
    async with get_session() as session:
        existing = (
            await session.execute(select(User.id).where(User.email == email_n))
        ).scalar_one_or_none()
        if existing is not None:
            raise HTTPException(status_code=409, detail="Email này đã được đăng ký.")

        user = User(
            id=uuid.uuid4(),
            email=email_n,
            password_hash=pwd_hash,
            full_name=body.fullName,
            email_verified=False,
        )
        if body.unitId is not None:
            user.unit_id = body.unitId
        session.add(user)
        await session.flush()

        await _assert_unit_exists(session, user.unit_id)
        await _assert_academic_title_active(session, body.academicTitleCode)
        emp = normalize_employee_id(body.employeeId)

        staff = UserStaffProfile(
            user_id=user.id,
            employee_id=emp,
            academic_title_code=body.academicTitleCode,
            academic_title_other=body.academicTitleOther,
            unit_name_freetext=body.unitNameFreetext,
            job_title=body.jobTitle,
        )
        try:
            assert_unit_exclusive(user, staff)
        except ValueError as e:
            raise HTTPException(status_code=400, detail=str(e)) from e
        try:
            assert_complete_for_submission(user, staff)
        except ValueError as e:
            raise HTTPException(status_code=400, detail=str(e)) from e
        session.add(staff)
        try:
            await session.flush()
        except IntegrityError as e:
            raise HTTPException(
                status_code=409,
                detail="Mã số nhân sự đã được sử dụng hoặc dữ liệu không hợp lệ.",
            ) from e

        if email_n in policy:
            session.add(
                UserRoleRow(
                    user_id=user.id,
                    role="admin",
                    admin_from_email_policy=True,
                )
            )
        else:
            session.add(
                UserRoleRow(
                    user_id=user.id,
                    role="viewer",
                    admin_from_email_policy=False,
                )
            )
        try:
            await session.flush()
        except IntegrityError:
            raise HTTPException(status_code=409, detail="Không thể gán vai trò — thử lại.") from None

        roles = await _roles_after_reconcile(session, user)
        actor_email, actor_role = await resolve_actor_fields(session, user.id)
        await record_audit(
            session,
            actor_user_id=user.id,
            actor_email=actor_email,
            actor_role=actor_role,
            action=AuditAction.create,
            entity_type="user",
            entity_id=str(user.id),
            after={
                "email": user.email,
                "fullName": user.full_name,
                "roles": roles,
            },
            metadata={"source": "auth_register"},
        )
        otp_plain = await _issue_registration_otp(session, user.id)
        public_user = _user_public_dict(user, roles, staff)
        mail_out = (email_n, otp_plain)

    otp_delivery = "none"
    if mail_out is not None:
        try:
            ch = await deliver_registration_otp_email(mail_out[0], mail_out[1])
            if ch in ("smtp", "log_only", "none"):
                otp_delivery = ch
        except Exception as e:
            import logging

            logging.getLogger(__name__).exception("register: OTP mail failed: %s", e)
            otp_delivery = "smtp_failed" if mail_delivery_configured() else "none"

    return {
        "message": _register_success_otp_message(),
        "email": email_n,
        "emailVerificationRequired": True,
        "otpTtlSeconds": int(REGISTER_OTP_TTL.total_seconds()),
        "otpDeliveryChannel": otp_delivery,
        "user": public_user,
    }


@router.post("/login")
async def login(body: LoginBody, request: Request) -> dict[str, Any]:
    if not is_postgres_enabled():
        raise HTTPException(
            status_code=503,
            detail="Đăng nhập qua máy chủ yêu cầu cơ sở dữ liệu.",
        )

    email_n = _normalize_institutional_email(body.email)
    if not allow_login(email_n, _client_ip(request)):
        raise HTTPException(
            status_code=429,
            detail="Quá nhiều lần đăng nhập. Vui lòng thử lại sau.",
        )

    user: User | None = None
    roles: list[str] = []
    staff_profile: UserStaffProfile | None = None
    login_ok = False
    needs_email_verify = False
    failed_uid: uuid.UUID | None = None
    failed_roles = "none"

    async with get_session() as session:
        stmt = select(User).where(User.email == email_n, User.is_active.is_(True))
        user = (await session.execute(stmt)).scalar_one_or_none()

        if user is not None and _verify_password(body.password, user.password_hash):
            if not user.email_verified:
                needs_email_verify = True
                failed_uid = user.id
                _, failed_roles = await resolve_actor_fields(session, user.id)
            else:
                roles = await _roles_after_reconcile(session, user)
                actor_email, actor_role = await resolve_actor_fields(session, user.id)
                await record_audit(
                    session,
                    actor_user_id=user.id,
                    actor_email=actor_email,
                    actor_role=actor_role,
                    action=AuditAction.login,
                    entity_type="auth",
                    entity_id=str(user.id),
                    metadata={"path": "/auth/login"},
                )
                staff_profile = await _load_staff_profile(session, user.id)
                login_ok = True
        elif user is not None:
            failed_uid = user.id
            _, failed_roles = await resolve_actor_fields(session, user.id)

    if needs_email_verify:
        await persist_audit_standalone(
            actor_user_id=failed_uid,
            actor_email=email_n,
            actor_role=failed_roles,
            action=AuditAction.login_failed,
            entity_type="auth",
            metadata={"event": "login_failed", "reason": "email_unverified"},
        )
        raise HTTPException(
            status_code=403,
            detail="Vui lòng xác minh email trước khi đăng nhập. Kiểm tra hộp thư "
            "hoặc dùng chức năng gửi lại mã OTP trên trang đăng ký.",
        )

    if not login_ok:
        await persist_audit_standalone(
            actor_user_id=failed_uid,
            actor_email=email_n,
            actor_role=failed_roles,
            action=AuditAction.login_failed,
            entity_type="auth",
            metadata={"event": "login_failed"},
        )
        raise HTTPException(status_code=401, detail="Email hoặc mật khẩu không đúng.")

    assert user is not None

    token = _issue_token(user.id, user.email, roles, int(user.credential_version))
    return {"accessToken": token, "user": _user_public_dict(user, roles, staff_profile)}


@router.post("/forgot-password")
async def forgot_password(body: ForgotPasswordBody, request: Request) -> dict[str, str]:
    """Always same response for unknown / inactive email; rate-limited."""
    if not is_postgres_enabled():
        return FORGOT_PASSWORD_RESPONSE

    email_n = _normalize_institutional_email(body.email)
    ip = _client_ip(request)
    if not allow_forgot_password(email_n, ip):
        raise HTTPException(
            status_code=429,
            detail="Quá nhiều yêu cầu. Vui lòng thử lại sau.",
        )

    if not mail_delivery_configured():
        import logging

        logging.getLogger(__name__).warning(
            "forgot-password: mail not configured (SMTP_HOST or AUTH_MAIL_LOG_ONLY)"
        )
        return FORGOT_PASSWORD_RESPONSE

    mail_to_send: tuple[str, str] | None = None
    async with get_session() as session:
        user = (
            await session.execute(
                select(User).where(User.email == email_n, User.is_active.is_(True))
            )
        ).scalar_one_or_none()

        if user is None:
            return FORGOT_PASSWORD_RESPONSE

        raw = secrets.token_urlsafe(32)
        th = _hash_reset_token(raw)
        await session.execute(
            delete(PasswordResetToken).where(
                PasswordResetToken.user_id == user.id,
                PasswordResetToken.used_at.is_(None),
            )
        )
        session.add(
            PasswordResetToken(
                user_id=user.id,
                token_hash=th,
                expires_at=datetime.now(timezone.utc) + RESET_TOKEN_TTL,
            )
        )
        await session.flush()
        actor_email, actor_role = await resolve_actor_fields(session, user.id)
        await record_audit(
            session,
            actor_user_id=user.id,
            actor_email=actor_email,
            actor_role=actor_role,
            action=AuditAction.update,
            entity_type="auth",
            entity_id=str(user.id),
            metadata={"source": "auth_forgot_password", "event": "password_reset_requested"},
        )
        mail_to_send = (str(user.email), raw)

    if mail_to_send:
        try:
            await deliver_password_reset_email(mail_to_send[0], mail_to_send[1])
        except Exception as e:
            import logging

            logging.getLogger(__name__).exception("forgot-password: mail send failed: %s", e)

    return FORGOT_PASSWORD_RESPONSE


@router.post("/reset-password")
async def reset_password(body: ResetPasswordBody, request: Request) -> dict[str, str]:
    if not is_postgres_enabled():
        raise HTTPException(status_code=503, detail="Cơ sở dữ liệu chưa cấu hình.")

    if not allow_reset_password(_client_ip(request)):
        raise HTTPException(
            status_code=429,
            detail="Quá nhiều yêu cầu. Vui lòng thử lại sau.",
        )

    if body.newPassword != body.newPasswordConfirm:
        raise HTTPException(status_code=400, detail="Mật khẩu xác nhận không khớp.")
    _assert_password_policy(body.newPassword)

    raw = body.token.strip()
    if not raw or len(raw) < 10:
        raise HTTPException(status_code=400, detail="Liên kết không hợp lệ hoặc đã hết hạn.")

    th = _hash_reset_token(raw)
    now = datetime.now(timezone.utc)

    async with get_session() as session:
        row = (
            await session.execute(
                select(PasswordResetToken).where(PasswordResetToken.token_hash == th)
            )
        ).scalar_one_or_none()

        if row is None or row.used_at is not None or row.expires_at <= now:
            raise HTTPException(status_code=400, detail="Liên kết không hợp lệ hoặc đã hết hạn.")

        user = await session.get(User, row.user_id)
        if user is None or not user.is_active:
            raise HTTPException(status_code=400, detail="Liên kết không hợp lệ hoặc đã hết hạn.")

        user.password_hash = _hash_password(body.newPassword)
        user.credential_version = int(user.credential_version or 0) + 1
        user.updated_at = now
        row.used_at = now
        await session.flush()

        actor_email, actor_role = await resolve_actor_fields(session, user.id)
        await record_audit(
            session,
            actor_user_id=user.id,
            actor_email=actor_email,
            actor_role=actor_role,
            action=AuditAction.update,
            entity_type="user",
            entity_id=str(user.id),
            before={"password": "[redacted]"},
            after={"password": "[changed]"},
            metadata={"source": "auth_reset_password", "event": "password_reset_completed"},
        )

    return PASSWORD_RESET_SUCCESS


class VerifyEmailBody(BaseModel):
    model_config = ConfigDict(extra="ignore")

    token: str = Field(..., min_length=10, max_length=512)


class ResendVerificationBody(BaseModel):
    model_config = ConfigDict(extra="ignore")

    email: str = Field(..., min_length=5, max_length=254)


class VerifyRegistrationOtpBody(BaseModel):
    model_config = ConfigDict(extra="ignore")

    email: str = Field(..., min_length=5, max_length=254)
    otp: str = Field(..., min_length=6, max_length=32)

    @field_validator("email")
    @classmethod
    def strip_email_votp(cls, v: object) -> str:
        s = str(v).strip().lower()
        if not s:
            raise ValueError("Vui lòng nhập email.")
        return s

    @field_validator("otp")
    @classmethod
    def otp_six_digits(cls, v: object) -> str:
        s = str(v).strip()
        if not re.fullmatch(r"\d{6}", s):
            raise ValueError("Mã gồm đúng 6 chữ số.")
        return s


class ResendRegistrationOtpBody(BaseModel):
    model_config = ConfigDict(extra="ignore")

    email: str = Field(..., min_length=5, max_length=254)

    @field_validator("email")
    @classmethod
    def strip_email_resend_otp(cls, v: object) -> str:
        return str(v).strip().lower()


@router.post("/verify-otp")
async def verify_registration_otp(body: VerifyRegistrationOtpBody) -> dict[str, str]:
    if not is_postgres_enabled():
        raise HTTPException(status_code=503, detail="Cơ sở dữ liệu chưa cấu hình.")

    try:
        email_n = _normalize_institutional_email(body.email)
    except HTTPException:
        raise HTTPException(status_code=400, detail=_OTP_VERIFY_REJECT_DETAIL) from None

    otp_hash_expected = _hash_reset_token(body.otp)
    now = datetime.now(timezone.utc)

    async with get_session() as session:
        user = (
            await session.execute(
                select(User).where(User.email == email_n, User.is_active.is_(True))
            )
        ).scalar_one_or_none()

        if user is None or user.email_verified:
            raise HTTPException(status_code=400, detail=_OTP_VERIFY_REJECT_DETAIL)

        stmt = (
            select(RegistrationOtpCode)
            .where(
                RegistrationOtpCode.user_id == user.id,
                RegistrationOtpCode.used_at.is_(None),
                RegistrationOtpCode.expires_at > now,
                RegistrationOtpCode.failed_attempts < REGISTER_OTP_MAX_FAILED_ATTEMPTS,
            )
            .order_by(RegistrationOtpCode.created_at.desc())
            .limit(1)
        )
        row = (await session.execute(stmt)).scalar_one_or_none()

        if row is None:
            raise HTTPException(status_code=400, detail=_OTP_VERIFY_REJECT_DETAIL)

        if not hmac.compare_digest(row.otp_hash, otp_hash_expected):
            row.failed_attempts += 1
            await session.flush()
            raise HTTPException(status_code=400, detail=_OTP_VERIFY_REJECT_DETAIL)

        user.email_verified = True
        user.updated_at = now
        row.used_at = now
        await session.flush()

        actor_email, actor_role = await resolve_actor_fields(session, user.id)
        await record_audit(
            session,
            actor_user_id=user.id,
            actor_email=actor_email,
            actor_role=actor_role,
            action=AuditAction.update,
            entity_type="user",
            entity_id=str(user.id),
            after={"emailVerified": True},
            metadata={"source": "auth_verify_registration_otp"},
        )

    return VERIFY_EMAIL_SUCCESS


@router.post("/resend-otp")
async def resend_registration_otp(body: ResendRegistrationOtpBody, request: Request) -> dict[str, str]:
    """Enumeration-safe envelope (same response whether email exists / needs OTP)."""
    if not is_postgres_enabled():
        return RESEND_OTP_RESPONSE

    try:
        email_n = _normalize_institutional_email(body.email)
    except HTTPException:
        return RESEND_OTP_RESPONSE

    ip = _client_ip(request)
    if not allow_resend_registration_otp(email_n, ip):
        raise HTTPException(
            status_code=429,
            detail="Quá nhiều yêu cầu. Vui lòng thử lại sau.",
        )

    if not mail_delivery_configured():
        import logging

        logging.getLogger(__name__).warning(
            "resend-otp: mail not configured (SMTP_HOST or AUTH_MAIL_LOG_ONLY)"
        )
        return RESEND_OTP_RESPONSE

    mail_to_send: tuple[str, str] | None = None
    async with get_session() as session:
        user = (
            await session.execute(
                select(User).where(User.email == email_n, User.is_active.is_(True))
            )
        ).scalar_one_or_none()

        if user is not None and not user.email_verified:
            code = await _issue_registration_otp(session, user.id)
            await session.flush()
            actor_email, actor_role = await resolve_actor_fields(session, user.id)
            await record_audit(
                session,
                actor_user_id=user.id,
                actor_email=actor_email,
                actor_role=actor_role,
                action=AuditAction.update,
                entity_type="auth",
                entity_id=str(user.id),
                metadata={"source": "auth_resend_registration_otp"},
            )
            mail_to_send = (str(user.email), code)

    if mail_to_send is not None:
        try:
            await deliver_registration_otp_email(mail_to_send[0], mail_to_send[1])
        except Exception as e:
            import logging

            logging.getLogger(__name__).exception("resend-otp: mail send failed: %s", e)

    return RESEND_OTP_RESPONSE


@router.post("/verify-email")
async def verify_email(body: VerifyEmailBody) -> dict[str, str]:
    if not is_postgres_enabled():
        raise HTTPException(status_code=503, detail="Cơ sở dữ liệu chưa cấu hình.")

    raw = body.token.strip()
    if len(raw) < 10:
        raise HTTPException(status_code=400, detail="Liên kết không hợp lệ hoặc đã hết hạn.")

    th = _hash_reset_token(raw)
    now = datetime.now(timezone.utc)

    async with get_session() as session:
        row = (
            await session.execute(
                select(EmailVerificationToken).where(EmailVerificationToken.token_hash == th)
            )
        ).scalar_one_or_none()

        if row is None or row.used_at is not None or row.expires_at <= now:
            raise HTTPException(status_code=400, detail="Liên kết không hợp lệ hoặc đã hết hạn.")

        user = await session.get(User, row.user_id)
        if user is None or not user.is_active:
            raise HTTPException(status_code=400, detail="Liên kết không hợp lệ hoặc đã hết hạn.")

        user.email_verified = True
        user.updated_at = now
        row.used_at = now
        await session.flush()

        actor_email, actor_role = await resolve_actor_fields(session, user.id)
        await record_audit(
            session,
            actor_user_id=user.id,
            actor_email=actor_email,
            actor_role=actor_role,
            action=AuditAction.update,
            entity_type="user",
            entity_id=str(user.id),
            after={"emailVerified": True},
            metadata={"source": "auth_verify_email"},
        )

    return VERIFY_EMAIL_SUCCESS


@router.post("/resend-verification")
async def resend_verification(body: ResendVerificationBody, request: Request) -> dict[str, str]:
    """Same envelope whether or not the account exists / needs verification (enumeration-safe)."""
    if not is_postgres_enabled():
        return RESEND_VERIFICATION_RESPONSE

    email_n = _normalize_institutional_email(body.email)
    ip = _client_ip(request)
    if not allow_resend_verification(email_n, ip):
        raise HTTPException(
            status_code=429,
            detail="Quá nhiều yêu cầu. Vui lòng thử lại sau.",
        )

    if not mail_delivery_configured():
        import logging

        logging.getLogger(__name__).warning(
            "resend-verification: mail not configured (SMTP_HOST or AUTH_MAIL_LOG_ONLY)"
        )
        return RESEND_VERIFICATION_RESPONSE

    mail_to_send: tuple[str, str] | None = None
    async with get_session() as session:
        user = (
            await session.execute(
                select(User).where(User.email == email_n, User.is_active.is_(True))
            )
        ).scalar_one_or_none()

        if user is not None and not user.email_verified:
            code = await _issue_registration_otp(session, user.id)
            await session.flush()
            actor_email, actor_role = await resolve_actor_fields(session, user.id)
            await record_audit(
                session,
                actor_user_id=user.id,
                actor_email=actor_email,
                actor_role=actor_role,
                action=AuditAction.update,
                entity_type="auth",
                entity_id=str(user.id),
                metadata={"source": "auth_resend_verification"},
            )
            mail_to_send = (str(user.email), code)

    if mail_to_send:
        try:
            await deliver_registration_otp_email(mail_to_send[0], mail_to_send[1])
        except Exception as e:
            import logging

            logging.getLogger(__name__).exception("resend-verification: mail send failed: %s", e)

    return RESEND_VERIFICATION_RESPONSE


@router.post("/refresh")
async def refresh_session(authorization: str | None = Header(None)) -> dict[str, Any]:
    if not authorization or not authorization.lower().startswith("bearer "):
        raise HTTPException(401, detail="Thiếu token.")
    raw = authorization.split(None, 1)[1].strip()
    try:
        payload = jwt.decode(raw, jwt_secret_key(), algorithms=["HS256"])
    except jwt.ExpiredSignatureError:
        raise HTTPException(
            status_code=401,
            detail="Phiên đăng nhập hết hạn. Vui lòng đăng nhập lại.",
        ) from None
    except jwt.PyJWTError:
        raise HTTPException(401, detail="Token không hợp lệ.") from None

    try:
        uid = uuid.UUID(str(payload["sub"]))
    except (ValueError, KeyError):
        raise HTTPException(401, detail="Token không hợp lệ.") from None

    jwt_cv = jwt_credential_version_from_payload(payload)

    async with get_session() as session:
        user = await session.get(User, uid)
        if user is None or not user.is_active:
            raise HTTPException(401, detail="Tài khoản không còn hiệu lực.")
        if not user.email_verified:
            raise HTTPException(
                status_code=403,
                detail="Vui lòng xác minh email trước khi tiếp tục.",
            )
        db_cv = int(user.credential_version or 0)
        if jwt_cv != db_cv:
            raise HTTPException(
                status_code=401,
                detail="Phiên đăng nhập hết hạn. Vui lòng đăng nhập lại.",
            )
        roles = await _roles_after_reconcile(session, user)
        sp = await _load_staff_profile(session, user.id)

    token = _issue_token(user.id, user.email, roles, db_cv)
    return {"accessToken": token, "user": _user_public_dict(user, roles, sp)}


@router.post("/logout")
async def logout(authorization: str | None = Header(None)) -> Response:
    """Client clears JWT locally; audit row only when Bearer decodes cleanly."""
    if is_postgres_enabled():
        payload = decode_bearer_token(authorization)
        if payload:
            uid = decode_access_token_user_id(authorization)
            email, roles_csv = jwt_payload_actor_email(payload)
            await persist_audit_standalone(
                actor_user_id=uid,
                actor_email=email if email else "<unknown>",
                actor_role=roles_csv,
                action=AuditAction.logout,
                entity_type="auth",
                entity_id=str(uid) if uid else None,
                metadata={"path": "/auth/logout"},
            )
    return Response(status_code=204)


@router.get("/reference/academic-titles")
async def reference_academic_titles() -> list[dict[str, Any]]:
    if not is_postgres_enabled():
        return []
    async with get_session() as session:
        stmt = (
            select(AcademicTitle)
            .where(AcademicTitle.active.is_(True))
            .order_by(AcademicTitle.sort_order, AcademicTitle.code)
        )
        rows = (await session.execute(stmt)).scalars().all()
        return [{"code": r.code, "labelVi": r.label_vi, "labelEn": r.label_en} for r in rows]


@router.get("/reference/units")
async def reference_units() -> list[dict[str, str]]:
    if not is_postgres_enabled():
        return []
    async with get_session() as session:
        stmt = select(Unit).order_by(Unit.name)
        rows = (await session.execute(stmt)).scalars().all()
        return [{"id": str(r.id), "name": r.name} for r in rows]


@router.get("/me")
async def get_current_profile(authorization: str | None = Header(None)) -> dict[str, Any]:
    if not is_postgres_enabled():
        raise HTTPException(
            status_code=503,
            detail="Hồ sơ yêu cầu cơ sở dữ liệu.",
        )
    uid = decode_access_token_user_id(authorization)
    if uid is None:
        raise HTTPException(401, detail="Vui lòng đăng nhập.")

    async with get_session() as session:
        user = await session.get(User, uid)
        if user is None or not user.is_active:
            raise HTTPException(401, detail="Tài khoản không còn hiệu lực.")
        if not user.email_verified:
            raise HTTPException(
                status_code=403,
                detail="Vui lòng xác minh email trước khi tiếp tục.",
            )
        roles = await _roles_after_reconcile(session, user)
        sp = await _load_staff_profile(session, user.id)
        return _user_public_dict(user, roles, sp)


class UpdateProfileBody(BaseModel):
    model_config = ConfigDict(extra="ignore")

    fullName: str | None = None
    phone: str | None = None
    employeeId: str | None = None
    academicTitleCode: str | None = None
    academicTitleOther: str | None = None
    unitId: uuid.UUID | None = None
    unitNameFreetext: str | None = None
    jobTitle: str | None = None

    @field_validator("fullName", mode="before")
    @classmethod
    def strip_full_name(cls, v: object) -> str | None:
        if v is None:
            return None
        s = str(v).strip()
        if len(s) < 2:
            raise ValueError("Họ tên phải có ít nhất 2 ký tự.")
        return s

    @field_validator("phone", mode="before")
    @classmethod
    def strip_phone(cls, v: object) -> str | None:
        if v is None or v == "":
            return None
        s = str(v).strip()
        if len(s) > 32:
            raise ValueError("Số điện thoại quá dài.")
        return s

    @field_validator("academicTitleCode", mode="before")
    @classmethod
    def strip_title_code(cls, v: object) -> str | None:
        if v is None or v == "":
            return None
        s = str(v).strip()
        return s or None

    @field_validator("academicTitleOther", "unitNameFreetext", "jobTitle", mode="before")
    @classmethod
    def strip_optional_text(cls, v: object) -> str | None:
        if v is None or v == "":
            return None
        s = str(v).strip()
        return s or None

    @field_validator("employeeId", mode="before")
    @classmethod
    def strip_employee(cls, v: object) -> str | None:
        if v is None or v == "":
            return None
        return str(v).strip()


@router.patch("/profile")
async def update_profile(
    body: UpdateProfileBody, authorization: str | None = Header(None)
) -> dict[str, Any]:
    if not is_postgres_enabled():
        raise HTTPException(
            status_code=503,
            detail="Cập nhật hồ sơ yêu cầu cơ sở dữ liệu.",
        )
    uid = decode_access_token_user_id(authorization)
    if uid is None:
        raise HTTPException(401, detail="Vui lòng đăng nhập.")

    patch = body.model_dump(exclude_unset=True)
    if not patch:
        raise HTTPException(400, detail="Không có dữ liệu để cập nhật.")

    now = datetime.now(timezone.utc)
    async with get_session() as session:
        user = await session.get(User, uid)
        if user is None or not user.is_active:
            raise HTTPException(401, detail="Tài khoản không còn hiệu lực.")
        sp = await _load_staff_profile(session, user.id)
        if sp.profile_verification_status == "pending":
            raise HTTPException(
                status_code=409,
                detail="Hồ sơ đang chờ xác minh — không thể chỉnh sửa cho đến khi quản trị xử lý.",
            )
        staff_before = staff_row_for_audit(sp, user.unit_id)
        user_before = {"fullName": user.full_name, "phone": user.phone}

        if "fullName" in patch and patch["fullName"] is not None:
            user.full_name = str(patch["fullName"])
        if "phone" in patch:
            user.phone = patch["phone"] if patch["phone"] else None

        if "unitId" in patch:
            user.unit_id = patch["unitId"]
            if patch["unitId"] is not None:
                sp.unit_name_freetext = None
            await _assert_unit_exists(session, user.unit_id)

        if "unitNameFreetext" in patch:
            sp.unit_name_freetext = patch["unitNameFreetext"]
            if sp.unit_name_freetext:
                user.unit_id = None

        if "employeeId" in patch:
            emp = normalize_employee_id(patch.get("employeeId"))
            try:
                assert_employee_id_shape(emp)
            except ValueError as e:
                raise HTTPException(status_code=400, detail=str(e)) from e
            sp.employee_id = emp

        if "academicTitleCode" in patch:
            code = patch["academicTitleCode"]
            await _assert_academic_title_active(session, code)
            sp.academic_title_code = code
            if code != "other":
                sp.academic_title_other = None

        if "academicTitleOther" in patch:
            sp.academic_title_other = patch["academicTitleOther"]

        if "jobTitle" in patch:
            sp.job_title = patch["jobTitle"]

        try:
            assert_unit_exclusive(user, sp)
        except ValueError as e:
            raise HTTPException(status_code=400, detail=str(e)) from e

        staff_after_partial = staff_row_for_audit(sp, user.unit_id)
        if material_staff_fields_changed(staff_before, staff_after_partial):
            sp.version += 1
            if sp.profile_verification_status == "verified":
                apply_reverify_from_verified(sp, now)

        user.updated_at = now
        sp.updated_at = now
        try:
            await session.flush()
        except IntegrityError as e:
            raise HTTPException(
                status_code=409,
                detail="Mã số nhân sự đã được sử dụng hoặc dữ liệu không hợp lệ.",
            ) from e

        staff_final = staff_row_for_audit(sp, user.unit_id)
        roles = await _roles_after_reconcile(session, user)
        actor_email, actor_role = await resolve_actor_fields(session, user.id)
        user_after = {"fullName": user.full_name, "phone": user.phone}
        if user_before != user_after:
            await record_audit(
                session,
                actor_user_id=user.id,
                actor_email=actor_email,
                actor_role=actor_role,
                action=AuditAction.update,
                entity_type="user",
                entity_id=str(user.id),
                before=user_before,
                after=user_after,
                metadata={"source": "auth_profile"},
            )
        if staff_before != staff_final:
            await record_audit(
                session,
                actor_user_id=user.id,
                actor_email=actor_email,
                actor_role=actor_role,
                action=AuditAction.update,
                entity_type="user_profile",
                entity_id=str(user.id),
                before=staff_before,
                after=staff_final,
                metadata={"source": "auth_profile_staff"},
            )
        return _user_public_dict(user, roles, sp)


@router.post("/profile/submit-verification")
async def submit_profile_verification(authorization: str | None = Header(None)) -> dict[str, Any]:
    if not is_postgres_enabled():
        raise HTTPException(status_code=503, detail="Cơ sở dữ liệu chưa cấu hình.")
    uid = decode_access_token_user_id(authorization)
    if uid is None:
        raise HTTPException(401, detail="Vui lòng đăng nhập.")

    now = datetime.now(timezone.utc)
    async with get_session() as session:
        user = await session.get(User, uid)
        if user is None or not user.is_active:
            raise HTTPException(401, detail="Tài khoản không còn hiệu lực.")
        sp = await _load_staff_profile(session, user.id)
        if sp.profile_verification_status not in ("draft", "rejected"):
            raise HTTPException(status_code=400, detail="Chỉ gửi xác minh khi hồ sơ ở trạng thái nháp hoặc bị từ chối.")
        try:
            assert_complete_for_submission(user, sp)
        except ValueError as e:
            raise HTTPException(status_code=400, detail=str(e)) from e
        before = staff_row_for_audit(sp, user.unit_id)
        sp.profile_verification_status = "pending"
        sp.verification_submitted_at = now
        sp.rejection_reason = None
        sp.verified_at = None
        sp.verified_by_user_id = None
        sp.version += 1
        sp.updated_at = now
        await session.flush()
        after = staff_row_for_audit(sp, user.unit_id)
        roles = await _roles_after_reconcile(session, user)
        actor_email, actor_role = await resolve_actor_fields(session, user.id)
        await record_audit(
            session,
            actor_user_id=user.id,
            actor_email=actor_email,
            actor_role=actor_role,
            action=AuditAction.update,
            entity_type="user_profile",
            entity_id=str(user.id),
            before=before,
            after=after,
            metadata={"source": "auth_submit_verification"},
        )
        return _user_public_dict(user, roles, sp)


class ChangePasswordBody(BaseModel):
    currentPassword: str = Field(..., min_length=1)
    newPassword: str = Field(..., min_length=1)
    newPasswordConfirm: str = Field(..., min_length=1)


@router.post("/change-password")
async def change_password(
    body: ChangePasswordBody, authorization: str | None = Header(None)
) -> dict[str, Any]:
    if not is_postgres_enabled():
        raise HTTPException(
            status_code=503,
            detail="Đổi mật khẩu yêu cầu cơ sở dữ liệu.",
        )
    if body.newPassword != body.newPasswordConfirm:
        raise HTTPException(400, detail="Mật khẩu mới xác nhận không khớp.")
    _assert_password_policy(body.newPassword)

    uid = decode_access_token_user_id(authorization)
    if uid is None:
        raise HTTPException(401, detail="Vui lòng đăng nhập.")

    async with get_session() as session:
        user = await session.get(User, uid)
        if user is None or not user.is_active:
            raise HTTPException(401, detail="Tài khoản không còn hiệu lực.")
        if not _verify_password(body.currentPassword, user.password_hash):
            raise HTTPException(400, detail="Mật khẩu hiện tại không đúng.")

        user.password_hash = _hash_password(body.newPassword)
        user.credential_version = int(user.credential_version or 0) + 1
        user.updated_at = datetime.now(timezone.utc)
        await session.flush()
        actor_email, actor_role = await resolve_actor_fields(session, user.id)
        await record_audit(
            session,
            actor_user_id=user.id,
            actor_email=actor_email,
            actor_role=actor_role,
            action=AuditAction.update,
            entity_type="user",
            entity_id=str(user.id),
            before={"password": "[redacted]"},
            after={"password": "[changed]"},
            metadata={"source": "auth_change_password"},
        )
        roles = await _roles_after_reconcile(session, user)
        sp = await _load_staff_profile(session, user.id)
        user_payload = _user_public_dict(user, roles, sp)
        new_cv = int(user.credential_version)

    token = _issue_token(uid, user_payload["email"], roles, new_cv)
    return {"accessToken": token, "user": user_payload}
