"""Authentication module models.

Implements the four tables of the approved Authentication database design:

* :class:`User` (``users``) - account and security data
* :class:`UserProfile` (``user_profiles``) - personal profile data
* :class:`EmailVerification` (``email_verifications``) - verification tokens
* :class:`PasswordResetToken` (``password_reset_tokens``) - reset tokens

Authentication data is deliberately separated from personal profile information.
These models define schema only; password hashing, token generation and all
authentication logic live in the service layer.
"""

from __future__ import annotations

from flask_login import UserMixin

from app.extensions import db
from app.models.base import BaseModel, BigIntegerType, utcnow
from app.models.enums import (
    AccountStatus,
    StudentVerificationStatus,
    UserRole,
    VerificationHistoryAction,
    VerificationPurpose,
)
from app.models.mixins import CreatedAtMixin, PublicIdMixin, TimestampMixin


def _enum_values(enum_cls):
    """Persist the ENUM member *values* (e.g. ``"student"``) rather than names."""
    return [member.value for member in enum_cls]


class User(UserMixin, BaseModel, PublicIdMixin, TimestampMixin):
    """A registered user account.

    Holds authentication and account-management data only. A single account can
    both buy and sell. Personal details are stored in :class:`UserProfile`.

    Inherits :class:`flask_login.UserMixin` for the session-auth interface
    (``is_authenticated``, ``get_id``, etc.); ``get_id`` returns the integer
    primary key as a string, consumed by the ``user_loader``.
    """

    __tablename__ = "users"
    __table_args__ = (
        db.Index("ix_users_role", "role"),
        db.Index("ix_users_account_status", "account_status"),
        db.Index("ix_users_locked_until", "locked_until"),
        db.Index("ix_users_created_at", "created_at"),
    )

    email = db.Column(db.String(255), nullable=False, unique=True)
    password_hash = db.Column(db.String(255), nullable=False)
    role = db.Column(
        db.Enum(UserRole, values_callable=_enum_values, name="user_role"),
        nullable=False,
        default=UserRole.STUDENT,
    )
    account_status = db.Column(
        db.Enum(AccountStatus, values_callable=_enum_values, name="account_status"),
        nullable=False,
        default=AccountStatus.ACTIVE,
    )
    # Email verification status (the approved "is_verified" column). Verification
    # state is always set **explicitly** at each creation site — public
    # registration passes ``False`` (and drives the OTP flow), while admin/CLI/
    # seed helpers pass ``True``. The column keeps the documented ``FALSE``
    # default purely as a safe fallback; no caller relies on it implicitly.
    is_verified = db.Column(db.Boolean, nullable=False, default=False)
    # When the email was verified (nullable — never verified yet is NULL).
    verified_at = db.Column(db.DateTime, nullable=True)
    last_login = db.Column(db.DateTime, nullable=True)
    failed_login_attempts = db.Column(db.Integer, nullable=False, default=0)
    locked_until = db.Column(db.DateTime, nullable=True)
    last_password_change = db.Column(db.DateTime, nullable=True)
    # Per-account Email-OTP two-factor toggle. When True, sign-ins from an
    # untrusted device must pass an emailed OTP even if the global login-OTP gate
    # is off. Reuses the existing OTP framework (no separate 2FA mechanism).
    two_factor_enabled = db.Column(db.Boolean, nullable=False, default=False, server_default="0")
    # JSON map of the user's security email-notification preferences (new login,
    # new device, password changed, 2FA changed, failed logins, alerts). NULL =
    # all defaults on. Kept as a single column to avoid a preferences table.
    security_notification_prefs = db.Column(db.Text, nullable=True)

    # Relationships (auth module). Cross-module relationships such as listings
    # and offers are declared by their own modules once those models exist.
    profile = db.relationship(
        "UserProfile",
        back_populates="user",
        uselist=False,
        cascade="all, delete-orphan",
    )
    email_verifications = db.relationship(
        "EmailVerification",
        back_populates="user",
        cascade="all, delete-orphan",
    )
    password_reset_tokens = db.relationship(
        "PasswordResetToken",
        back_populates="user",
        cascade="all, delete-orphan",
    )

    @property
    def email_verified(self) -> bool:
        """Readable alias for :attr:`is_verified` (the email-verification flag).

        The verification feature refers to this state as ``email_verified``; the
        underlying approved column is ``is_verified``. Exposing an alias avoids a
        duplicate column while keeping call sites expressive."""
        return bool(self.is_verified)

    @email_verified.setter
    def email_verified(self, value: bool) -> None:
        self.is_verified = bool(value)

    @property
    def student_verification_status(self) -> StudentVerificationStatus:
        """The account's moderated Verified-Student status (from the profile)."""
        if self.profile is None:
            return StudentVerificationStatus.NOT_VERIFIED
        return self.profile.verification_status

    @property
    def is_student_verified(self) -> bool:
        """``True`` only when an admin has approved the Verified Student badge.

        This is the **single** source of truth for the badge — email
        verification (:attr:`is_verified`) never grants it."""
        return self.student_verification_status == StudentVerificationStatus.VERIFIED

    def is_premium_active(self) -> bool:
        """Return ``True`` when the user has an active, non-expired subscription.

        Delegates to :func:`app.premium.services.is_user_premium` so premium
        entitlement is resolved in exactly one place. Imported lazily to avoid a
        circular import at model-load time.
        """
        from app.premium.services import is_user_premium

        return is_user_premium(self)

    def __repr__(self) -> str:
        return f"<User id={self.id} email={self.email!r}>"


class UserProfile(BaseModel, PublicIdMixin, TimestampMixin):
    """Personal profile information for a user (one profile per user)."""

    __tablename__ = "user_profiles"

    user_id = db.Column(
        BigIntegerType,
        db.ForeignKey("users.id"),
        nullable=False,
        unique=True,
    )
    full_name = db.Column(db.String(150), nullable=False)
    profile_picture = db.Column(db.String(255), nullable=True)
    phone_number = db.Column(db.String(20), nullable=True)  # reused as the verification mobile number
    bio = db.Column(db.Text, nullable=True)

    # --- Student verification (moderated Verified-Student badge) ---------------
    # Submitted by the student and approved/rejected by an admin. The mobile
    # number reuses ``phone_number`` above (no duplicate field).
    university_name = db.Column(db.String(150), nullable=True)
    university_student_id = db.Column(db.String(60), nullable=True)
    verification_status = db.Column(
        db.Enum(StudentVerificationStatus, values_callable=_enum_values, name="student_verification_status"),
        nullable=False,
        default=StudentVerificationStatus.NOT_VERIFIED,
        server_default=StudentVerificationStatus.NOT_VERIFIED.value,
    )
    verification_submitted_at = db.Column(db.DateTime, nullable=True)
    verification_reviewed_at = db.Column(db.DateTime, nullable=True)
    verification_reviewed_by = db.Column(BigIntegerType, nullable=True)  # admin user id (no FK, mirrors reviewed_by elsewhere)
    verification_reason = db.Column(db.String(300), nullable=True)       # rejection reason / review note

    user = db.relationship("User", back_populates="profile", foreign_keys=[user_id])

    def __repr__(self) -> str:
        return f"<UserProfile id={self.id} user_id={self.user_id}>"


class EmailVerification(BaseModel, CreatedAtMixin):
    """A single-use verification code belonging to a user.

    Despite the historical table name, this is the storage for the generic
    verification framework (:mod:`app.auth.otp_service`): the ``purpose`` column
    isolates codes by intent (email verification, password reset, 2FA…) and
    ``verification_token`` holds the HMAC-SHA256 digest of the code — never the
    code itself. One physical table backs every verification purpose."""

    __tablename__ = "email_verifications"
    __table_args__ = (
        db.Index("ix_email_verifications_user_purpose", "user_id", "purpose"),
    )

    user_id = db.Column(BigIntegerType, db.ForeignKey("users.id"), nullable=False)
    purpose = db.Column(
        db.Enum(VerificationPurpose, values_callable=_enum_values, name="verification_purpose"),
        nullable=False,
        default=VerificationPurpose.EMAIL_VERIFICATION,
        server_default=VerificationPurpose.EMAIL_VERIFICATION.value,
    )
    verification_token = db.Column(db.CHAR(64), nullable=False, unique=True)
    expires_at = db.Column(db.DateTime, nullable=False)
    verified_at = db.Column(db.DateTime, nullable=True)

    user = db.relationship("User", back_populates="email_verifications")

    def __repr__(self) -> str:
        return f"<EmailVerification id={self.id} user_id={self.user_id} purpose={self.purpose}>"


class TemporaryRegistration(BaseModel, CreatedAtMixin):
    """A pending registration held **before** any user account is created.

    Enables a "verify-first" registration: the account (``users`` row + profile)
    is created only once the emailed OTP is confirmed, so the database never
    accumulates unverified users. The row is short-lived — it expires after
    ``TEMP_REGISTRATION_TTL_MINUTES`` and is deleted on successful verification.

    The OTP itself is **never stored in plain text**: ``code_hash`` holds the
    same HMAC-SHA256 digest produced by the shared verification framework
    (:mod:`app.auth.otp_service`), so no OTP logic is duplicated here."""

    __tablename__ = "temporary_registrations"
    __table_args__ = (
        db.Index("ix_temp_registrations_email", "email"),
        db.Index("ix_temp_registrations_expires_at", "expires_at"),
    )

    full_name = db.Column(db.String(150), nullable=False)
    email = db.Column(db.String(255), nullable=False)
    password_hash = db.Column(db.String(255), nullable=False)   # already hashed — never plaintext
    code_hash = db.Column(db.CHAR(64), nullable=False)
    code_expires_at = db.Column(db.DateTime, nullable=False)    # OTP lifetime (short)
    expires_at = db.Column(db.DateTime, nullable=False)         # registration-session lifetime
    resend_count = db.Column(db.Integer, nullable=False, default=0)
    attempts = db.Column(db.Integer, nullable=False, default=0)
    last_sent_at = db.Column(db.DateTime, nullable=False, default=utcnow)

    def __repr__(self) -> str:
        return f"<TemporaryRegistration id={self.id} email={self.email!r}>"


class StudentVerificationHistory(BaseModel, CreatedAtMixin):
    """Immutable audit record of one student-verification lifecycle event.

    Exactly one row is **appended** per SUBMITTED / UPDATED / APPROVED / REJECTED
    event by :mod:`app.auth.verification` — rows are never updated or deleted.
    Submissions snapshot the submitted values; moderation decisions store the
    reviewing admin and any reason. This is the source of truth for both the
    admin timeline and the student's own (reviewer/reason-free) history, and
    exists because the admin audit log only records admin actions (not student
    submissions or the submitted values)."""

    __tablename__ = "student_verification_history"
    __table_args__ = (
        db.Index("ix_svh_user_created", "user_id", "created_at"),
    )

    user_id = db.Column(BigIntegerType, db.ForeignKey("users.id"), nullable=False)
    action = db.Column(
        db.Enum(VerificationHistoryAction, values_callable=_enum_values, name="verification_history_action"),
        nullable=False,
    )
    # Resulting verification status at the time of the event (snapshot).
    status = db.Column(
        db.Enum(StudentVerificationStatus, values_callable=_enum_values, name="verification_history_status"),
        nullable=False,
    )
    # Snapshot of the submitted identity values (SUBMITTED / UPDATED only).
    submitted_phone = db.Column(db.String(20), nullable=True)
    submitted_university = db.Column(db.String(150), nullable=True)
    submitted_student_id = db.Column(db.String(60), nullable=True)
    # Moderation metadata (APPROVED / REJECTED only). Admin id, no FK — mirrors
    # reviewed_by elsewhere; visible to admins only.
    reviewed_by = db.Column(BigIntegerType, nullable=True)
    review_reason = db.Column(db.String(300), nullable=True)

    def __repr__(self) -> str:
        return f"<StudentVerificationHistory id={self.id} user_id={self.user_id} action={self.action}>"


class PasswordResetToken(BaseModel, CreatedAtMixin):
    """A single-use password reset token belonging to a user."""

    __tablename__ = "password_reset_tokens"

    user_id = db.Column(BigIntegerType, db.ForeignKey("users.id"), nullable=False)
    reset_token = db.Column(db.CHAR(64), nullable=False, unique=True)
    expires_at = db.Column(db.DateTime, nullable=False)
    used_at = db.Column(db.DateTime, nullable=True)

    user = db.relationship("User", back_populates="password_reset_tokens")

    def __repr__(self) -> str:
        return f"<PasswordResetToken id={self.id} user_id={self.user_id}>"


class TrustedDevice(BaseModel, CreatedAtMixin):
    """A browser a user has chosen to trust so the login-OTP step is skipped.

    Confirmed at the end of a login-verification flow ("Trust this device for 30
    days"). The raw device token is **never persisted** — it lives only in a
    secure, HttpOnly cookie; ``token_hash`` holds a salted SHA-256 digest of it
    (the same privacy-preserving hashing used for :class:`ListingView` and
    :class:`AdminAuditLog`), so a leaked table cannot be turned back into a usable
    cookie. Raw IP and user-agent are likewise stored only as salted hashes; a
    coarse, non-identifying ``device_label`` (e.g. "Chrome on Windows") is kept
    for the user's own device list.

    A device is trusted while ``expires_at`` is in the future and ``revoked_at``
    is NULL. Adding this table is the storage the approved Security Design
    anticipates under "Device Recognition" (§19); it introduces no change to any
    existing table."""

    __tablename__ = "trusted_devices"
    __table_args__ = (
        db.Index("ix_trusted_devices_user_id", "user_id"),
        db.Index("ix_trusted_devices_token_hash", "token_hash"),
        db.Index("ix_trusted_devices_expires_at", "expires_at"),
    )

    user_id = db.Column(BigIntegerType, db.ForeignKey("users.id"), nullable=False)
    # Salted SHA-256 of the raw cookie token — never the token itself.
    token_hash = db.Column(db.CHAR(64), nullable=False)
    # Coarse, non-PII descriptor for the user's device list (e.g. "Chrome on Windows").
    device_label = db.Column(db.String(120), nullable=True)
    # Optional user-chosen friendly name (e.g. "My laptop"); overrides the label
    # in the UI when set. Display-only — never used for authentication.
    name = db.Column(db.String(80), nullable=True)
    # Salted hashes only — raw IP / user-agent are never stored (privacy invariant).
    ip_hash = db.Column(db.CHAR(64), nullable=True)
    user_agent_hash = db.Column(db.CHAR(64), nullable=True)
    last_used_at = db.Column(db.DateTime, nullable=False, default=utcnow)
    expires_at = db.Column(db.DateTime, nullable=False)
    revoked_at = db.Column(db.DateTime, nullable=True)

    user = db.relationship("User", backref=db.backref("trusted_devices", cascade="all, delete-orphan"))

    @property
    def display_name(self) -> str:
        """User-chosen name if set, else the auto-derived browser/OS label."""
        return (self.name or "").strip() or self.device_label or "Unknown device"

    def __repr__(self) -> str:
        return f"<TrustedDevice id={self.id} user_id={self.user_id}>"


class SecurityEvent(BaseModel, CreatedAtMixin):
    """An append-only, per-user security event — the persistent store behind the
    Security page (login history, recent activity, failed-login monitoring,
    suspicious-activity checks).

    The existing :func:`app.auth.services.log_auth_event` dual-writes one row here
    for every user-scoped auth event it already emits, so there is a single audit
    call path (no second logger, no duplicated call sites). Privacy: the raw IP is
    **never** stored — only a masked display form (e.g. ``203.0.113.xxx``) and a
    salted SHA-256 hash for correlation; the user-agent is parsed to coarse
    browser/OS/device fields rather than stored verbatim."""

    __tablename__ = "security_events"
    __table_args__ = (
        db.Index("ix_security_events_user_created", "user_id", "created_at"),
        db.Index("ix_security_events_event", "event"),
    )

    user_id = db.Column(BigIntegerType, db.ForeignKey("users.id"), nullable=False)
    event = db.Column(db.String(48), nullable=False)        # AuthEvent name
    result = db.Column(db.String(16), nullable=True)        # success | failed | info
    method = db.Column(db.String(24), nullable=True)        # password | email_otp | trusted_device
    browser = db.Column(db.String(48), nullable=True)
    os = db.Column(db.String(48), nullable=True)
    device_type = db.Column(db.String(24), nullable=True)   # Desktop | Mobile | Tablet
    ip_display = db.Column(db.String(45), nullable=True)     # masked for display
    ip_hash = db.Column(db.CHAR(64), nullable=True)          # salted hash (correlation only)
    location = db.Column(db.String(80), nullable=True)       # City/Country when available
    detail = db.Column(db.String(255), nullable=True)

    user = db.relationship("User", backref=db.backref("security_events", cascade="all, delete-orphan"))

    def __repr__(self) -> str:
        return f"<SecurityEvent id={self.id} user_id={self.user_id} event={self.event!r}>"
