"""Authentication service layer.

Business logic for account creation and credential verification. Routes call
into these functions; passwords are hashed with Werkzeug and never stored or
returned in plain text.
"""

from __future__ import annotations

from datetime import timedelta

from flask import current_app
from werkzeug.security import check_password_hash, generate_password_hash

from app.extensions import db
from app.models import User, UserProfile
from app.models.base import utcnow


def update_profile(
    user: User,
    *,
    full_name: str,
    bio: str | None,
    preferred_meeting_location: str | None = None,
    response_preference: str | None = None,
) -> None:
    """Update editable personal profile information.

    Deliberately does **not** touch ``phone_number``: the phone is verified
    identity data owned solely by the Student Verification workflow, so it can
    never be changed through the Edit Profile form (server-side data integrity).
    """
    profile = user.profile
    if profile is None:
        profile = UserProfile(full_name=full_name.strip())
        user.profile = profile
    else:
        profile.full_name = full_name.strip()
        profile.bio = bio

    # The current schema does not persist preferred meeting location or
    # response preference in the user profile model. These fields are shown
    # in the UI as placeholders and are accepted without breaking the request.
    db.session.commit()


def save_profile_picture(user: User, filename: str) -> None:
    profile = user.profile
    if profile is None:
        profile = UserProfile(full_name=user.email)
        user.profile = profile
    profile.profile_picture = filename
    db.session.commit()


def change_password(user: User, current_password: str, new_password: str) -> bool:
    if not check_password_hash(user.password_hash, current_password):
        return False
    user.password_hash = generate_password_hash(new_password)
    user.last_password_change = utcnow()
    db.session.commit()
    log_auth_event(AuthEvent.PASSWORD_CHANGED, user=user)
    return True


class RegistrationError(Exception):
    """Raised when an account cannot be created (e.g. duplicate email)."""


def email_exists(email: str) -> bool:
    """Return True if an account already uses the given email."""
    normalized = email.strip().lower()
    return db.session.scalar(
        db.select(User).filter_by(email=normalized).limit(1)
    ) is not None


def create_user(full_name: str, email: str, password_hash: str, *, is_verified: bool) -> User:
    """Create a user + profile from an **already-hashed** password.

    The single account-creation primitive. Verification state is passed
    explicitly; a verified account also gets a ``verified_at`` stamp. Used by
    :func:`register_user` (unverified) and by the verify-first registration flow
    (verified, once the OTP is confirmed). Seeds the welcome activity."""
    normalized = email.strip().lower()
    user = User(
        email=normalized,
        password_hash=password_hash,
        is_verified=is_verified,
        verified_at=utcnow() if is_verified else None,
    )
    user.profile = UserProfile(full_name=full_name.strip())

    db.session.add(user)
    db.session.commit()

    # Seed the activity feed with a friendly welcome (feed only).
    from app.activity.services import publish_activity
    publish_activity(user, "welcome", "Welcome to Student Bazaar! 🎉",
                     message="Your account is ready. List an item or browse the marketplace to get started.")
    log_auth_event(AuthEvent.ACCOUNT_CREATED, user=user)
    return user


def register_user(full_name: str, email: str, password: str) -> User:
    """Create a new **unverified** user directly (lower-level utility).

    Kept for callers/tests that create an account without the OTP handshake. The
    public web registration now defers account creation until the email is
    verified (see :mod:`app.auth.registration`); this remains the primitive for
    creating an account from a plaintext password.
    """
    normalized = email.strip().lower()
    if email_exists(normalized):
        raise RegistrationError("An account with this email already exists.")
    return create_user(full_name, normalized, generate_password_hash(password), is_verified=False)


def mark_email_verified(user: User) -> None:
    """Apply the email-verification consequence once a code has been confirmed.

    This is the feature-specific side effect kept out of the generic
    verification framework: the framework validates the code, this flips the
    account to verified and stamps the timestamp."""
    user.is_verified = True
    user.verified_at = utcnow()
    db.session.commit()
    log_auth_event(AuthEvent.EMAIL_VERIFIED, user=user)


def change_pending_email(user: User, new_email: str) -> bool:
    """Update an unverified account's email address (e.g. a typo at sign-up).

    Only permitted while the account is still unverified — a verified account's
    email is immutable here. Returns ``False`` if the new address is already in
    use by another account.
    """
    if user.is_verified:
        return False
    normalized = new_email.strip().lower()
    if normalized == user.email:
        return True
    existing = db.session.scalar(
        db.select(User).filter_by(email=normalized).limit(1)
    )
    if existing is not None and existing.id != user.id:
        return False
    user.email = normalized
    db.session.commit()
    return True


def get_user_by_email(email: str) -> User | None:
    """Look up an account by (normalised) email, or ``None``."""
    normalized = (email or "").strip().lower()
    if not normalized:
        return None
    return db.session.scalar(db.select(User).filter_by(email=normalized).limit(1))


def authenticate(email: str, password: str) -> User | None:
    """Return the matching user when credentials are valid, else None."""
    user = get_user_by_email(email)
    if user is None or not check_password_hash(user.password_hash, password):
        return None
    return user


# --- Login attempt lockout & audit (Security_Design §7 / §15) ------------------
#
# Temporary account lockout reuses the two columns the approved schema already
# defines for it — ``users.failed_login_attempts`` and ``users.locked_until`` —
# so no schema change is needed. Thresholds are configuration, never hard-coded.

class AuthEvent:
    """Canonical names for security-audit events, so every emitter uses the same
    stable identifier through the shared :func:`log_auth_event` sink (there is no
    second logger). Referenced by the login routes, the trusted-device service
    and the password-reset service."""

    LOGIN_SUCCESS = "login_success"            # trusted-device / OTP-disabled immediate sign-in
    LOGIN_FAILED = "login_failed"
    ACCOUNT_LOCKED = "account_locked"
    LOGIN_OTP_SENT = "login_otp_sent"
    LOGIN_OTP_SUCCESS = "login_otp_success"
    LOGIN_OTP_FAILURE = "login_otp_failure"
    LOGIN_OTP_RESEND = "login_otp_resend"
    TRUSTED_DEVICE_CREATED = "trusted_device_created"
    TRUSTED_DEVICE_USED = "trusted_device_used"
    TRUSTED_DEVICE_REVOKED = "trusted_device_revoked"
    TRUSTED_DEVICE_RENAMED = "trusted_device_renamed"
    TRUSTED_DEVICES_REVOKED_AFTER_PASSWORD_RESET = "trusted_devices_revoked_after_password_reset"
    ACCOUNT_CREATED = "account_created"
    EMAIL_VERIFIED = "email_verified"
    PASSWORD_CHANGED = "password_changed"
    PASSWORD_RESET_COMPLETED = "password_reset_completed"
    TWO_FACTOR_ENABLED = "two_factor_enabled"
    TWO_FACTOR_DISABLED = "two_factor_disabled"
    SUSPICIOUS_LOGIN = "suspicious_login"


# Events that represent a sign-in attempt (drive the Login History view).
LOGIN_EVENTS = frozenset({
    AuthEvent.LOGIN_SUCCESS, AuthEvent.LOGIN_OTP_SUCCESS,
    AuthEvent.LOGIN_FAILED, AuthEvent.LOGIN_OTP_FAILURE, AuthEvent.ACCOUNT_LOCKED,
})
# Events treated as failures for the "result" column / red styling.
_FAILURE_EVENTS = frozenset({
    AuthEvent.LOGIN_FAILED, AuthEvent.LOGIN_OTP_FAILURE, AuthEvent.ACCOUNT_LOCKED,
})
_SUCCESS_EVENTS = frozenset({AuthEvent.LOGIN_SUCCESS, AuthEvent.LOGIN_OTP_SUCCESS})


def result_for(event: str) -> str:
    """Map an event name to a coarse result: ``success`` / ``failed`` / ``info``."""
    if event in _SUCCESS_EVENTS:
        return "success"
    if event in _FAILURE_EVENTS:
        return "failed"
    return "info"


def log_auth_event(event: str, *, user: User | None = None, ip: str | None = None,
                   method: str | None = None, persist: bool = True, **extra) -> None:
    """Record a security event: a structured app-log line **and** (when a user is
    known) a persisted :class:`~app.models.auth.SecurityEvent` row.

    This is the single audit sink for user-facing security events — every login,
    OTP, trusted-device and account event already flows through here, so enabling
    persistence needs no new call sites and no duplicated logic. Never logs
    credentials, OTP codes or email bodies. Persistence is best-effort: a failure
    is logged but never breaks the calling flow (mirrors the admin audit log).
    """
    parts = [f"event={event}"]
    if user is not None:
        parts.append(f"user={getattr(user, 'id', None)}")
    if ip:
        parts.append(f"ip={ip}")
    if method:
        parts.append(f"method={method}")
    parts += [f"{k}={v}" for k, v in extra.items()]
    current_app.logger.info("auth.%s", " ".join(parts))

    if persist and user is not None and getattr(user, "id", None) is not None:
        try:
            from flask import has_request_context, request as _request
            from app.auth.security_service import record_security_event

            record_security_event(
                user, event,
                request=_request if has_request_context() else None,
                ip=ip, method=method, detail=extra.get("detail"),
            )
        except Exception:  # pragma: no cover - audit must never break the flow
            current_app.logger.exception("Failed to persist security event %s", event)


def is_account_locked(user: User) -> bool:
    """True while the account is inside an active temporary lockout window."""
    return bool(user.locked_until and user.locked_until > utcnow())


def register_failed_login(user: User, *, ip: str | None = None) -> bool:
    """Record one failed password attempt; lock the account when the configured
    threshold is reached. Returns ``True`` if this attempt triggered a lock.

    Called only for a real account (unknown emails are audited without a user so
    account existence is never revealed by behaviour)."""
    user.failed_login_attempts = (user.failed_login_attempts or 0) + 1
    max_attempts = current_app.config.get("LOGIN_MAX_FAILED_ATTEMPTS", 10)
    locked = False
    if user.failed_login_attempts >= max_attempts:
        minutes = current_app.config.get("LOGIN_LOCKOUT_MINUTES", 15)
        user.locked_until = utcnow() + timedelta(minutes=minutes)
        locked = True
    db.session.commit()
    log_auth_event(AuthEvent.LOGIN_FAILED, user=user, ip=ip,
                   attempts=user.failed_login_attempts, locked=locked)
    if locked:
        log_auth_event(AuthEvent.ACCOUNT_LOCKED, user=user, ip=ip, minutes=minutes)
    return locked


def reset_login_failures(user: User) -> None:
    """Clear the failed-attempt counter and any lock after a valid credential
    check (Security_Design §7: a successful login resets the counter)."""
    if user.failed_login_attempts or user.locked_until:
        user.failed_login_attempts = 0
        user.locked_until = None
        db.session.commit()
