"""
Authentication routes for login, register, and logout.
Uses SQLCipher encrypted databases with browser password manager support.
"""

import threading
import time
from datetime import datetime, timezone, UTC

from flask import (
    Blueprint,
    flash,
    jsonify,
    redirect,
    render_template,
    request,
    session,
    url_for,
)
from loguru import logger

from ...database.auth_db import auth_db_session
from ...database.encrypted_db import DatabaseInitializationError, db_manager
from ...database.models.auth import User
from ...database.thread_local_session import thread_cleanup
from sqlalchemy.exc import IntegrityError
from ...utilities.threading_utils import thread_context, thread_with_app_context
from .session_manager import (
    session_manager,
)  # singleton from session_manager module
from ..server_config import load_server_config
from ...security.rate_limiter import (
    login_limit,
    password_change_limit,
    registration_limit,
)
from urllib.parse import urlparse

from ...security.url_validator import URLValidator
from ...security.account_lockout import get_account_lockout_manager
from ...security.password_validator import PasswordValidator
from ...security.log_sanitizer import sanitize_for_log

auth_bp = Blueprint("auth", __name__, url_prefix="/auth")


def _create_user_session(session, username: str, password: str, remember: bool):
    """Create a new Flask session for ``username``.

    Runs the common post-authentication sequence shared by login and
    register: clear any prior session data (session-fixation defence),
    create a server-side session, stash the username + remember flag,
    store the password in the temp-auth and session-password stores for
    post-login database access, and return the new session id.

    The caller owns logging, flashing, and redirects — this helper does
    only the data-layer work.
    """
    # Prevent session fixation by clearing old session data before creating new
    session.clear()

    # Create session
    session_id = session_manager.create_session(username, remember)
    session["session_id"] = session_id
    session["username"] = username
    session.permanent = remember

    # Store password temporarily for post-login database access
    from ...database.temp_auth import temp_auth_store

    auth_token = temp_auth_store.store_auth(username, password)
    session["temp_auth_token"] = auth_token

    # Also store in session password store for metrics access
    from ...database.session_passwords import session_password_store

    session_password_store.store_session_password(
        username, session_id, password
    )

    return session_id


def _cleanup_user_session(
    username: str, session_id=None, new_password=None
) -> None:
    """Tear down server-side session state for ``username``.

    Parameterises the two cleanup scopes used by the auth routes:

    * Single-session (logout) — pass ``session_id``: destroys just that
      session and clears its password-store entry. The caller is
      responsible for guarding on ``session_id`` being truthy.
    * All-sessions (change_password) — omit ``session_id``: destroys
      every session for the user and clears the whole password store.

    When ``new_password`` is supplied (change_password path only), an
    additional backup purge-and-refresh step runs so old-key encrypted
    backups are replaced with ones encrypted under the new key.

    The caller owns logging, flashing, redirects, scheduler unregister,
    database close, per-user lock cleanup, and the final
    ``session.clear()`` — this helper does only the session/password-store
    layer plus the optional backup refresh.
    """
    if session_id is not None:
        # Single-session scope (logout)
        session_manager.destroy_session(session_id)

        from ...database.session_passwords import session_password_store

        session_password_store.clear_session(username, session_id)
    else:
        # All-sessions scope (change_password)
        if new_password is not None:
            # Purge old backups (encrypted with old key) and create
            # a fresh backup with the new key. Old-key backups are a
            # security risk per NIST SP 800-57 / OWASP A02 — they remain
            # decryptable with the compromised password.
            try:
                from ...database.backup.backup_service import BackupService

                svc = BackupService(username=username, password=new_password)
                result = svc.purge_and_refresh()
                if result.success:
                    logger.info(
                        f"Backups refreshed after password change for {username}"
                    )
                else:
                    logger.error(
                        f"Post-password-change backup failed for {username}: "
                        f"{result.error}. Old backups were purged."
                    )
            except Exception:
                logger.exception(
                    f"Could not refresh backups after password change "
                    f"for {username}"
                )

        session_manager.destroy_all_user_sessions(username)

        from ...database.session_passwords import session_password_store

        session_password_store.clear_all_for_user(username)


@auth_bp.route("/csrf-token", methods=["GET"])
def get_csrf_token():
    """
    Get CSRF token for API requests.
    Returns the current CSRF token for the session.
    This endpoint makes it easy for API clients to get the CSRF token
    programmatically without parsing HTML.
    """
    from flask_wtf.csrf import generate_csrf

    # Generate or get existing CSRF token for this session
    token = generate_csrf()

    return jsonify({"csrf_token": token}), 200


@auth_bp.route("/login", methods=["GET"])
def login_page():
    """
    Login page (GET only).
    Not rate limited - viewing the page should always work.
    """
    config = load_server_config()
    # Check if already logged in
    if session.get("username"):
        return redirect(url_for("index"))

    # Preserve the next parameter for post-login redirect
    next_page = request.args.get("next", "")

    return render_template(
        "auth/login.html",
        has_encryption=db_manager.has_encryption,
        allow_registrations=config.get("allow_registrations", True),
        next_page=next_page,
    )


@auth_bp.route("/login", methods=["POST"])
@login_limit
def login():
    """
    Login handler (POST only).
    Rate limited to 5 attempts per 15 minutes per IP to prevent brute force attacks.
    """
    config = load_server_config()
    # POST - Handle login
    username = request.form.get("username", "").strip()
    password = request.form.get("password", "")
    remember = request.form.get("remember", "false") == "true"

    if not username or not password:
        flash("Username and password are required", "error")
        return render_template(
            "auth/login.html",
            has_encryption=db_manager.has_encryption,
            allow_registrations=config.get("allow_registrations", True),
        ), 400

    # Check account lockout before attempting credential verification
    lockout_mgr = get_account_lockout_manager()
    if lockout_mgr.is_locked(username):
        logger.warning(
            f"Login attempt for locked account: {sanitize_for_log(username)}"
        )
        flash("Account is temporarily locked. Please try again later.", "error")
        return render_template(
            "auth/login.html",
            has_encryption=db_manager.has_encryption,
            allow_registrations=config.get("allow_registrations", True),
        ), 429

    # Try to open user's encrypted database. Two distinct failure modes:
    #   - return None  → credentials invalid OR DB missing → 401, count toward lockout
    #   - raise DatabaseInitializationError → credentials valid but schema
    #     can't be brought up (e.g. world-writable migrations dir tripping
    #     the alembic_runner permission check) → 503, do NOT count toward
    #     lockout. The user's password is correct; punishing them with a
    #     lockout for a server-side configuration problem would be wrong.
    try:
        engine = db_manager.open_user_database(username, password)
    except DatabaseInitializationError:
        logger.warning(
            f"Login refused for {sanitize_for_log(username)}: "
            "database initialisation failed (see traceback above). "
            "Lockout counter NOT incremented — credentials are valid."
        )
        flash(
            "Database initialisation failed. The server is misconfigured — "
            "please check the server logs or contact the administrator.",
            "error",
        )
        return render_template(
            "auth/login.html",
            has_encryption=db_manager.has_encryption,
            allow_registrations=config.get("allow_registrations", True),
        ), 503

    if engine is None:
        # Invalid credentials or database doesn't exist
        lockout_mgr.record_failure(username)
        logger.warning(
            f"Failed login attempt for username: {sanitize_for_log(username)}"
        )
        flash("Invalid username or password", "error")
        return render_template(
            "auth/login.html",
            has_encryption=db_manager.has_encryption,
            allow_registrations=config.get("allow_registrations", True),
        ), 401

    # Success — clear any prior failure count
    lockout_mgr.record_success(username)

    # Create session (clears old data, creates server-side session,
    # stashes credentials for post-login DB access).
    _create_user_session(session, username, password, remember)

    logger.info(f"User {username} logged in successfully")

    # Defer non-critical post-login work to a background thread so the
    # redirect returns immediately (settings migration, library init,
    # news scheduler notify, and backup scheduling are all idempotent
    # and can safely run after the response).
    app_ctx = thread_context()
    thread = threading.Thread(
        target=thread_with_app_context(_perform_post_login_tasks),
        args=(app_ctx, username, password),
        daemon=True,
    )
    thread.start()

    next_page = request.args.get("next", "")
    safe_path = URLValidator.get_safe_redirect_path(next_page, request.host_url)
    if safe_path:
        safe_path = safe_path.replace("\\", "/")
        parsed = urlparse(safe_path)
        if not parsed.scheme and not parsed.netloc:
            return redirect(safe_path)
    return redirect(url_for("index"))


@thread_cleanup
def _perform_post_login_tasks(username: str, password: str) -> None:
    """Run non-critical post-login operations in a background thread.

    Each operation is wrapped in its own try/except so that one failure
    does not prevent the others from running. All operations here are
    idempotent and safe to retry on the next login.

    An outer try/except wraps the whole body so any exception that
    escapes the per-step handlers (for example a failure inside a
    ``with`` context manager's __enter__ / __exit__) is logged loudly
    with a traceback instead of dying silently in the daemon thread.
    """
    try:
        _perform_post_login_tasks_body(username, password)
    except Exception:
        logger.exception(
            f"Post-login background thread crashed for user {username}"
        )


def _perform_post_login_tasks_body(username: str, password: str) -> None:
    """Body of _perform_post_login_tasks — split out so the outer
    try/except in the wrapper catches anything the per-step handlers
    miss. See _perform_post_login_tasks for rationale."""
    total_start = time.perf_counter()

    # 1. Settings version check + migration
    #
    # ATOMICITY INVARIANT: the defaults import and the `app.version`
    # marker MUST be written in one `get_user_db_session(...)` scope
    # with a single terminal `db_session.commit()`. SQLite WAL rollback
    # then guarantees either both land or neither does — the only
    # acceptable states for `db_version_matches_package()` to behave
    # correctly on the next login. Splitting into two commits regresses
    # to the "sticky loop": `app.version` stays unwritten, every
    # subsequent login re-runs the ~498-row bulk insert (app.version is
    # not in default_settings.json, only `update_db_version()` writes
    # it). Do not factor these calls into separate sessions or allow
    # `load_from_defaults_file`/`update_db_version` to commit internally
    # here — both must be called with `commit=False`.
    step_start = time.perf_counter()
    try:
        from ...settings.manager import SettingsManager
        from ...database.session_context import get_user_db_session

        with get_user_db_session(username, password) as db_session:
            settings_manager = SettingsManager(db_session)
            if not settings_manager.db_version_matches_package():
                logger.info(
                    f"Database version mismatch for {username} "
                    "- loading missing default settings"
                )
                settings_manager.load_from_defaults_file(
                    commit=False, overwrite=False
                )
                settings_manager.update_db_version(commit=False)
                db_session.commit()
                logger.info(
                    f"Missing default settings loaded and version "
                    f"updated for user {username}"
                )
    except Exception:
        logger.exception(f"Post-login settings migration failed for {username}")
    _log_step_duration("step 1 (settings version check)", step_start, username)

    # 2. Initialize library system (source types and default collection)
    step_start = time.perf_counter()
    try:
        from ...database.library_init import initialize_library_for_user

        init_results = initialize_library_for_user(username, password)
        if init_results.get("success"):
            logger.info(f"Library system initialized for user {username}")
        else:
            logger.warning(
                f"Library initialization issue for {username}: "
                f"{init_results.get('error', 'Unknown error')}"
            )
    except Exception:
        logger.exception(f"Post-login library init failed for {username}")
    _log_step_duration("step 2 (library init)", step_start, username)

    # 3. Update last_login in auth DB + notify news scheduler
    step_start = time.perf_counter()
    try:
        with auth_db_session() as auth_db:
            user = auth_db.query(User).filter_by(username=username).first()
            if user:
                user.last_login = datetime.now(UTC)

            try:
                from ...scheduler.background import (
                    get_background_job_scheduler,
                )

                scheduler = get_background_job_scheduler()
                if scheduler.is_running:
                    scheduler.update_user_info(username, password)
                    logger.info(
                        f"Updated scheduler with user info for {username}"
                    )
            except Exception:
                logger.exception("Could not update scheduler on login")

            auth_db.commit()
    except Exception:
        logger.exception(f"Post-login auth DB update failed for {username}")
    _log_step_duration(
        "step 3 (auth DB + scheduler notify)", step_start, username
    )

    # Model cache refresh is handled by /api/settings/available-models
    # via its 24h TTL and explicit force_refresh=true flag.

    # 4. Schedule background database backup if enabled
    step_start = time.perf_counter()
    try:
        from ...database.backup import get_backup_executor
        from ...settings.manager import SettingsManager
        from ...database.session_context import get_user_db_session

        with get_user_db_session(username, password) as db_session:
            sm = SettingsManager(db_session)
            backup_enabled = sm.get_setting("backup.enabled", True)

            if backup_enabled:
                max_backups = sm.get_setting("backup.max_count", 1)
                max_age_days = sm.get_setting("backup.max_age_days", 7)

                get_backup_executor().submit_backup(
                    username, password, max_backups, max_age_days
                )
                logger.info(f"Background backup scheduled for user {username}")
    except Exception:
        logger.exception(f"Post-login backup scheduling failed for {username}")
    _log_step_duration("step 4 (schedule backup)", step_start, username)

    total_ms = (time.perf_counter() - total_start) * 1000
    if total_ms > 1000:
        logger.info(
            f"Post-login tasks completed for user {username} "
            f"(total: {total_ms:.0f}ms)"
        )
    else:
        logger.info(
            f"Post-login tasks completed for user {username} ({total_ms:.0f}ms)"
        )


def _log_step_duration(step_label: str, start: float, username: str) -> None:
    """Log post-login step duration at INFO if > 100ms, else DEBUG."""
    elapsed_ms = (time.perf_counter() - start) * 1000
    if elapsed_ms > 100:
        logger.info(
            f"Post-login {step_label} for {username} took {elapsed_ms:.0f}ms"
        )
    else:
        logger.debug(
            f"Post-login {step_label} for {username} took {elapsed_ms:.0f}ms"
        )


@auth_bp.route("/validate-password", methods=["POST"])
def validate_password():
    """Validate password strength via API (used by client-side forms)."""
    password = request.form.get("password", "")
    errors = PasswordValidator.validate_strength(password)
    return jsonify({"valid": len(errors) == 0, "errors": errors})


@auth_bp.route("/register", methods=["GET"])
def register_page():
    """
    Registration page (GET only).
    Not rate limited - viewing the page should always work.
    """
    config = load_server_config()
    if not config.get("allow_registrations", True):
        flash("New user registrations are currently disabled.", "error")
        return redirect(url_for("auth.login_page"))

    return render_template(
        "auth/register.html",
        has_encryption=db_manager.has_encryption,
        password_requirements=PasswordValidator.get_requirements(),
    )


@auth_bp.route("/register", methods=["POST"])
@registration_limit
def register():
    """
    Registration handler (POST only).
    Creates new encrypted database for user with clear warnings about password recovery.
    Rate limited to 3 attempts per hour per IP to prevent registration spam.
    """
    config = load_server_config()
    if not config.get("allow_registrations", True):
        flash("New user registrations are currently disabled.", "error")
        return redirect(url_for("auth.login_page"))

    # POST - Handle registration
    username = request.form.get("username", "").strip()
    password = request.form.get("password", "")
    confirm_password = request.form.get("confirm_password", "")
    acknowledge = request.form.get("acknowledge", "false") == "true"

    # Validation
    errors = []

    if not username:
        errors.append("Username is required")
    elif len(username) < 3:
        errors.append("Username must be at least 3 characters")
    elif not username.replace("_", "").replace("-", "").isalnum():
        errors.append(
            "Username can only contain letters, numbers, underscores, and hyphens"
        )

    if not password:
        errors.append("Password is required")
    else:
        errors.extend(PasswordValidator.validate_strength(password))

    if password != confirm_password:
        errors.append("Passwords do not match")

    if not acknowledge:
        errors.append(
            "You must acknowledge that password recovery is not possible"
        )

    # Check if user already exists
    # Use generic error message to prevent account enumeration
    # Note: While this creates a minor timing difference, it's acceptable because:
    # 1. Rate limiting prevents automated timing analysis
    # 2. Generic error message prevents content-based enumeration
    # 3. Local database query timing is minimal (no network calls)
    # 4. Better UX with immediate feedback outweighs minor timing risk
    # See: https://cheatsheetseries.owasp.org/cheatsheets/Authentication_Cheat_Sheet.html
    if not errors and username and db_manager.user_exists(username):
        errors.append("Registration failed. Please try a different username.")

    if errors:
        for error in errors:
            flash(error, "error")
        return render_template(
            "auth/register.html",
            has_encryption=db_manager.has_encryption,
            password_requirements=PasswordValidator.get_requirements(),
        ), 400

    # Create user in auth database
    with auth_db_session() as auth_db:
        try:
            new_user = User(username=username)
            auth_db.add(new_user)
            auth_db.commit()
        except IntegrityError:
            # Catch duplicate username specifically (race condition case)
            # This handles the edge case where two requests for the same username
            # pass the user_exists() check simultaneously
            logger.warning(f"Duplicate username attempted: {username}")
            auth_db.rollback()
            flash(
                "Registration failed. Please try a different username.", "error"
            )
            return render_template(
                "auth/register.html",
                has_encryption=db_manager.has_encryption,
                password_requirements=PasswordValidator.get_requirements(),
            ), 400
        except Exception:
            logger.exception(f"Registration failed for {username}")
            auth_db.rollback()
            flash("Registration failed. Please try again.", "error")
            return render_template(
                "auth/register.html",
                has_encryption=db_manager.has_encryption,
                password_requirements=PasswordValidator.get_requirements(),
            ), 500

    try:
        # Create encrypted database for user
        db_manager.create_user_database(username, password)

        # Auto-login after registration (remember=False: fresh
        # registrations should not persist as "remember me" sessions).
        _create_user_session(session, username, password, remember=False)

        # Notify the news scheduler about the new user
        try:
            from ...scheduler.background import (
                get_background_job_scheduler,
            )

            scheduler = get_background_job_scheduler()
            if scheduler.is_running:
                scheduler.update_user_info(username, password)
                logger.info(
                    f"Updated scheduler with new user info for {username}"
                )
        except Exception:
            logger.exception("Could not update scheduler on registration")

        logger.info(f"New user registered: {username}")

        # Initialize library system (source types and default collection)
        from ...database.library_init import initialize_library_for_user

        try:
            init_results = initialize_library_for_user(username, password)
            if init_results.get("success"):
                logger.info(
                    f"Library system initialized for new user {username}"
                )
            else:
                logger.warning(
                    f"Library initialization issue for {username}: {init_results.get('error', 'Unknown error')}"
                )
        except Exception:
            logger.exception(
                f"Error initializing library for new user {username}"
            )
            # Don't block registration on library init failure

        return redirect(url_for("index"))

    except Exception:
        logger.exception(f"Registration failed for {username}")
        flash("Registration failed. Please try again.", "error")
        return render_template(
            "auth/register.html",
            has_encryption=db_manager.has_encryption,
            password_requirements=PasswordValidator.get_requirements(),
        ), 500


@auth_bp.route("/logout", methods=["POST"])
def logout():
    """
    Logout handler.
    Clears session and closes database connections.
    POST-only to prevent CSRF-triggered logout via GET (e.g. <img src="/auth/logout">).
    """
    username = session.get("username")
    session_id = session.get("session_id")

    if username:
        # LOGOUT CLEANUP ORDER (order matters):
        # 1. Unregister from news scheduler — removes password from scheduler's
        #    user_sessions dict and cancels scheduled jobs. Must happen BEFORE
        #    close_user_database() because: scheduler jobs fetch the password
        #    from user_sessions at runtime to call open_user_database(). If we
        #    close the DB first, a running job that already has the password
        #    can re-create the engine. Removing the password first ensures
        #    future job invocations can't authenticate.
        #    Note: a narrow race remains — a job that already fetched the
        #    password (but hasn't called open_user_database yet) can still
        #    recreate an engine. This is benign: the dead-thread sweep will
        #    clean it up within 60 seconds.
        # 2. Close database connection — disposes QueuePool engine and cleans
        #    up thread engines for this user.
        # 3. Destroy Flask session — invalidates session token.
        # 4. Clear session password store — removes password from secondary store.
        # 5. Clear Flask session dict — removes all session data.
        try:
            from ...scheduler.background import (
                get_background_job_scheduler,
            )

            sched = get_background_job_scheduler()
            if sched.is_running:
                sched.unregister_user(username)
        except Exception:
            logger.warning("Could not unregister user from scheduler")

        # Close database connection
        db_manager.close_user_database(username)

        # Drop per-user lock-dict entries (library-init, backup,
        # queue-processor critical sections). Matches the cleanup
        # done by the idle-connection sweeper; without this, those
        # three module-level dicts accumulate one entry per username
        # across the process lifetime.
        from .connection_cleanup import _pop_per_user_locks

        _pop_per_user_locks(username)

        # Clear session
        if session_id:
            _cleanup_user_session(username, session_id=session_id)

        session.clear()

        logger.info(f"User {username} logged out")
        flash("You have been logged out successfully", "info")

    return redirect(url_for("auth.login"))


@auth_bp.route("/check", methods=["GET"])
def check_auth():
    """
    Check if user is authenticated (for AJAX requests).
    """
    if session.get("username"):
        return jsonify({"authenticated": True, "username": session["username"]})
    return jsonify({"authenticated": False}), 401


@auth_bp.route("/change-password", methods=["GET"])
def change_password_page():
    """
    Change password page (GET only).
    Not rate limited - viewing the page should always work.
    """
    username = session.get("username")
    if not username:
        return redirect(url_for("auth.login"))

    return render_template(
        "auth/change_password.html",
        password_requirements=PasswordValidator.get_requirements(),
    )


@auth_bp.route("/change-password", methods=["POST"])
@password_change_limit
def change_password():
    """
    Change password handler (POST only).
    Requires current password and re-encrypts database.
    Rate limited to prevent brute-force of current password.
    """
    username = session.get("username")
    if not username:
        return redirect(url_for("auth.login"))

    # POST - Handle password change
    current_password = request.form.get("current_password", "")
    new_password = request.form.get("new_password", "")
    confirm_password = request.form.get("confirm_password", "")

    # Validation
    errors = []

    if not current_password:
        errors.append("Current password is required")

    if not new_password:
        errors.append("New password is required")
    else:
        errors.extend(PasswordValidator.validate_strength(new_password))

    if new_password != confirm_password:
        errors.append("New passwords do not match")

    if current_password == new_password:
        errors.append("New password must be different from current password")

    if errors:
        for error in errors:
            flash(error, "error")
        return render_template(
            "auth/change_password.html",
            password_requirements=PasswordValidator.get_requirements(),
        ), 400

    # Attempt password change
    success = db_manager.change_password(
        username, current_password, new_password
    )

    if success:
        # The rekey is the ONLY step needed.  The auth database stores no
        # password hash — login works by attempting to decrypt the user's
        # SQLCipher database.  Do NOT add an auth-DB password-hash update
        # here; it would fail (User model has no set_password method) and
        # is architecturally unnecessary.

        # Clean up stale credentials before clearing session
        # (mirrors logout handler cleanup steps 1–5).

        # 1. Unregister from scheduler (removes stale credential)
        try:
            from ...scheduler.background import (
                get_background_job_scheduler,
            )

            sched = get_background_job_scheduler()
            if sched.is_running:
                sched.unregister_user(username)
        except Exception:
            logger.warning(
                "Could not unregister user from scheduler",
            )

        # 2. Close database connection (disposes old-password engine)
        # change_password() already closes in its finally block, but
        # an explicit close here is defensive — harmless if redundant.
        db_manager.close_user_database(username)

        # 2a. Drop per-user lock-dict entries (matches logout path).
        from .connection_cleanup import _pop_per_user_locks

        _pop_per_user_locks(username)

        # 3. Destroy ALL sessions for this user + clear password store,
        #    and refresh backups encrypted under the new key (old-key
        #    backups are a security risk — see _cleanup_user_session).
        _cleanup_user_session(
            username, session_id=None, new_password=new_password
        )

        # 4. Clear Flask session dict
        session.clear()

        logger.info(f"Password changed for user {username}")
        flash(
            "Password changed successfully. Please login with your new password.",
            "success",
        )
        return redirect(url_for("auth.login"))
    flash("Current password is incorrect", "error")
    return render_template(
        "auth/change_password.html",
        password_requirements=PasswordValidator.get_requirements(),
    ), 401


@auth_bp.route("/integrity-check", methods=["GET"])
def integrity_check():
    """
    Check database integrity for current user.
    """
    username = session.get("username")
    if not username:
        return jsonify({"error": "Not authenticated"}), 401

    is_valid = db_manager.check_database_integrity(username)

    return jsonify(
        {
            "username": username,
            "integrity": "valid" if is_valid else "corrupted",
            "timestamp": datetime.now(timezone.utc).isoformat(),
        }
    )
