from datetime import datetime

from flask import (
    Blueprint,
    current_app,
    flash,
    redirect,
    render_template,
    request,
    send_from_directory,
    session,
    url_for,
)
from flask_babel import gettext as _
from flask_login import current_user, login_required, login_user, logout_user

from app import db, limiter, log_event, oauth, track_event
from app.config import Config
from app.models import User
from app.utils.cache import get_cache
from app.utils.auth_method import (
    auth_includes_ldap,
    auth_includes_local,
    auth_includes_oidc,
    forgot_password_available,
    normalize_auth_method,
    requires_password_form,
)
from app.utils.config_manager import ConfigManager
from app.utils.db import safe_commit
from app.utils.posthog_funnels import track_onboarding_started

auth_bp = Blueprint("auth", __name__)

# Allowed file extensions for user avatars (avoid SVG due to XSS risk)
ALLOWED_AVATAR_EXTENSIONS = {"png", "jpg", "jpeg", "gif", "webp"}


def allowed_avatar_file(filename: str) -> bool:
    return "." in filename and filename.rsplit(".", 1)[1].lower() in ALLOWED_AVATAR_EXTENSIONS


def get_avatar_upload_folder() -> str:
    """Get the upload folder path for user avatars and ensure it exists."""
    import os

    # Store avatars in /data volume to persist between container updates
    upload_folder = os.path.join(current_app.config.get("UPLOAD_FOLDER", "/data/uploads"), "avatars")
    os.makedirs(upload_folder, exist_ok=True)
    return upload_folder


def _login_template_vars():
    """Common template variables for auth/login.html, including demo mode when enabled."""
    allow_self_register = ConfigManager.get_setting("allow_self_register", Config.ALLOW_SELF_REGISTER)
    auth_method = normalize_auth_method(current_app.config.get("AUTH_METHOD", "local"))
    requires_password = requires_password_form(auth_method)
    vars = {
        "allow_self_register": allow_self_register,
        "auth_method": auth_method,
        "requires_password": requires_password,
        "auth_includes_ldap": auth_includes_ldap(auth_method),
        "auth_includes_oidc": auth_includes_oidc(auth_method),
        "show_forgot_password": forgot_password_available(auth_method) and auth_method != "ldap",
    }
    if current_app.config.get("DEMO_MODE"):
        vars["demo_mode"] = True
        vars["demo_username"] = (current_app.config.get("DEMO_USERNAME") or "demo").strip().lower()
        vars["demo_password"] = current_app.config.get("DEMO_PASSWORD", "demo")
    else:
        vars["demo_mode"] = False
    return vars


def _password_reset_serializer():
    from itsdangerous import URLSafeTimedSerializer

    return URLSafeTimedSerializer(Config.SECRET_KEY, salt="timetracker:password-reset:v1")


def _make_password_reset_token(user: User) -> str:
    s = _password_reset_serializer()
    return s.dumps({"uid": user.id, "ph": user.password_hash or ""})


def _verify_password_reset_token(token: str, *, max_age_seconds: int) -> User | None:
    from itsdangerous import BadSignature, SignatureExpired

    s = _password_reset_serializer()
    try:
        data = s.loads(token, max_age=max_age_seconds)
    except (SignatureExpired, BadSignature):
        return None
    try:
        uid = int(data.get("uid"))
    except Exception:
        return None
    ph = (data.get("ph") or "").strip()
    user = User.query.get(uid)
    if not user or not user.is_active:
        return None
    # Invalidate token when password changed since token creation.
    if (user.password_hash or "") != ph:
        return None
    return user


def _finalize_login_after_verification(user: User, *, log_auth_method: str):
    """After successful local or LDAP verification: 2FA gate or establish session and redirect."""
    if getattr(user, "two_factor_enabled", False):
        session["pre_2fa_user_id"] = user.id
        next_page = request.args.get("next")
        if next_page and next_page.startswith("/"):
            session["pre_2fa_next"] = next_page
        else:
            session.pop("pre_2fa_next", None)
        return redirect(url_for("auth.two_factor"))

    from app.telemetry.otel_setup import business_span

    with business_span("auth.login", user_id=user.id, auth_method=log_auth_method):
        login_user(user, remember=True)

        if not user.roles and user.role:
            from app.utils.role_migration import migrate_single_user

            if migrate_single_user(user.id):
                current_app.logger.info(
                    "Auto-migrated user '%s' from legacy role '%s' to new role system",
                    user.username,
                    user.role,
                )

        user.update_last_login()
        current_app.logger.info("User '%s' logged in successfully", user.username)
        log_event("auth.login", user_id=user.id, auth_method=log_auth_method)

    import threading

    def track_login_async():
        try:
            track_event(user.id, "auth.login", {"auth_method": log_auth_method})
        except Exception:
            pass

    threading.Thread(target=track_login_async, daemon=True).start()

    if user.password_change_required:
        flash(_("You must change your password before continuing."), "warning")
        return redirect(url_for("auth.change_password"))

    try:
        require_admin_2fa = bool(getattr(Config, "REQUIRE_2FA_FOR_ADMINS", False))
    except Exception:
        require_admin_2fa = False
    if require_admin_2fa and user.role == "admin" and not getattr(user, "two_factor_enabled", False):
        flash(_("Administrator accounts must enable two-factor authentication."), "warning")
        return redirect(url_for("auth.two_factor_setup"))

    next_page = request.args.get("next")
    if not next_page or not next_page.startswith("/"):
        next_page = url_for("main.dashboard")
    current_app.logger.info("Redirecting '%s' to %s", user.username, next_page)
    flash(_("Welcome back, %(username)s!", username=user.username), "success")
    return redirect(next_page)


@auth_bp.route("/forgot-password", methods=["GET", "POST"])
@limiter.limit("5 per minute", methods=["POST"])
def forgot_password():
    if current_user.is_authenticated:
        return redirect(url_for("main.dashboard"))

    # Password reset only when local accounts may exist (not pure LDAP / OIDC-only).
    try:
        auth_method = normalize_auth_method(current_app.config.get("AUTH_METHOD", "local"))
    except Exception:
        auth_method = "local"
    if not forgot_password_available(auth_method):
        flash(_("Password reset is not available for this authentication method."), "warning")
        return redirect(url_for("auth.login"))

    if current_app.config.get("DEMO_MODE"):
        flash(_("Demo mode: password reset is disabled."), "warning")
        return redirect(url_for("auth.login"))

    if request.method == "POST":
        identifier = (request.form.get("identifier") or "").strip()
        # Do not reveal whether a user exists.
        flash(
            _(
                "If an account matches what you entered and email is configured, you'll receive a reset link shortly."
            ),
            "info",
        )

        try:
            from app.utils.validation import sanitize_input

            identifier = sanitize_input(identifier, max_length=200).strip().lower()
        except Exception:
            identifier = (identifier or "").strip().lower()

        user = None
        if identifier:
            user = User.query.filter((User.username == identifier) | (User.email == identifier)).first()

        if user and user.is_active and user.email and getattr(user, "auth_provider", "local") == "local":
            try:
                token = _make_password_reset_token(user)
                reset_url = url_for("auth.reset_password", token=token, _external=True)

                from app.utils.email import send_email

                subject = _("Reset your TimeTracker password")
                text_body = _(
                    "A password reset was requested for your account.\n\n"
                    "Use this link to set a new password:\n"
                    "%(url)s\n\n"
                    "If you did not request this, you can ignore this email.",
                    url=reset_url,
                )
                html_body = render_template("auth/emails/password_reset.html", reset_url=reset_url, user=user)
                send_email(subject=subject, recipients=[user.email], text_body=text_body, html_body=html_body)
                log_event("auth.password_reset_requested", user_id=user.id)
            except Exception:
                # Never leak details; logging handled by email util.
                pass

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

    return render_template("auth/forgot_password.html")


@auth_bp.route("/reset-password/<token>", methods=["GET", "POST"])
@limiter.limit("10 per minute", methods=["POST"])
def reset_password(token: str):
    if current_user.is_authenticated:
        return redirect(url_for("main.dashboard"))

    if current_app.config.get("DEMO_MODE"):
        flash(_("Demo mode: password reset is disabled."), "warning")
        return redirect(url_for("auth.login"))

    max_age = int(getattr(Config, "PASSWORD_RESET_TOKEN_MAX_AGE_SECONDS", 3600) or 3600)
    user = _verify_password_reset_token(token, max_age_seconds=max_age)
    if not user:
        flash(_("This reset link is invalid or has expired. Please request a new one."), "error")
        return redirect(url_for("auth.forgot_password"))

    if getattr(user, "auth_provider", "local") == "ldap":
        flash(_("Password reset is not available for this account."), "warning")
        return redirect(url_for("auth.login"))

    if request.method == "POST":
        new_password = (request.form.get("new_password") or "").strip()
        confirm_password = (request.form.get("confirm_password") or "").strip()

        if not new_password or len(new_password) < 8:
            flash(_("Password must be at least 8 characters long."), "error")
            return render_template("auth/reset_password.html", token=token)
        if new_password != confirm_password:
            flash(_("Passwords do not match."), "error")
            return render_template("auth/reset_password.html", token=token)

        try:
            user.set_password(new_password)
            user.password_change_required = False
            db.session.add(user)
            db.session.commit()
            log_event("auth.password_reset_completed", user_id=user.id)
            flash(_("Your password has been updated. You can now sign in."), "success")
            return redirect(url_for("auth.login"))
        except Exception:
            db.session.rollback()
            flash(_("Could not reset password due to a database error."), "error")
            return render_template("auth/reset_password.html", token=token)

    return render_template("auth/reset_password.html", token=token)


@auth_bp.route("/login", methods=["GET", "POST"])
@limiter.limit("5 per minute", methods=["POST"])  # rate limit login attempts
def login():
    """Login page. Local username login is allowed only if AUTH_METHOD != 'oidc'."""
    if request.method == "GET":
        try:
            current_app.logger.info("GET /login from %s", request.headers.get("X-Forwarded-For") or request.remote_addr)
        except Exception:
            pass

    if current_user.is_authenticated:
        return redirect(url_for("main.dashboard"))

    # Get authentication method from Flask app config (reads from environment)
    try:
        auth_method = normalize_auth_method(current_app.config.get("AUTH_METHOD", "local"))
    except Exception:
        auth_method = "local"

    requires_password = requires_password_form(auth_method)
    include_ldap = auth_includes_ldap(auth_method)
    include_local = auth_includes_local(auth_method)

    # If OIDC-only mode, redirect to OIDC login start
    if auth_method == "oidc":
        return redirect(url_for("auth.login_oidc", next=request.args.get("next")))

    if request.method == "POST":
        try:
            username = request.form.get("username", "").strip().lower()
            password = request.form.get("password", "")
            current_app.logger.info(
                "POST /login (username=%s, auth_method=%s) from %s",
                username or "<empty>",
                auth_method,
                request.headers.get("X-Forwarded-For") or request.remote_addr,
            )

            # Validate username input
            import re

            from app.utils.validation import sanitize_input

            try:
                if not username:
                    raise ValueError("Username is required")

                # Sanitize username to prevent injection
                username = sanitize_input(username, max_length=100)
                # Additional validation: only allow safe characters for usernames
                if not re.match(r"^[a-z0-9._-]+$", username):
                    raise ValueError("Username contains invalid characters")
                if len(username) < 1 or len(username) > 100:
                    raise ValueError("Username must be between 1 and 100 characters")
            except (ValueError, Exception) as e:
                log_event("auth.login_failed", reason="invalid_username", auth_method=auth_method)
                flash(_("Invalid username format"), "error")
                return render_template("auth/login.html", **_login_template_vars())

            # Demo mode: only the configured demo user can log in; no self-registration
            if current_app.config.get("DEMO_MODE"):
                demo_username = (current_app.config.get("DEMO_USERNAME") or "demo").strip().lower()
                if username != demo_username:
                    log_event(
                        "auth.login_failed",
                        username=username,
                        reason="demo_mode_only_demo_user",
                        auth_method=auth_method,
                    )
                    flash(_("Only the demo account can be used. Please use the credentials shown below."), "error")
                    return render_template("auth/login.html", **_login_template_vars())

            if auth_method == "ldap":
                from app.services.ldap_service import LDAPService

                if requires_password and not password:
                    log_event("auth.login_failed", reason="password_required", auth_method=auth_method)
                    flash(_("Password is required"), "error")
                    return render_template("auth/login.html", **_login_template_vars())
                ldap_user = LDAPService.authenticate(username, password)
                if ldap_user:
                    return _finalize_login_after_verification(ldap_user, log_auth_method="ldap")
                log_event("auth.login_failed", username=username, reason="ldap_auth_failed", auth_method=auth_method)
                flash(_("Invalid username or password"), "error")
                return render_template("auth/login.html", **_login_template_vars())

            # Normalize admin usernames from config
            try:
                admin_usernames = [u.strip().lower() for u in (Config.ADMIN_USERNAMES or [])]
            except Exception:
                admin_usernames = ["admin"]

            # Check if user exists
            user = User.query.filter_by(username=username).first()
            current_app.logger.info("User lookup for '%s': %s", username, "found" if user else "not found")

            if auth_method == "all" and not user and include_ldap:
                from app.services.ldap_service import LDAPService

                if password:
                    ldap_user = LDAPService.authenticate(username, password)
                    if ldap_user:
                        return _finalize_login_after_verification(ldap_user, log_auth_method="ldap")

            if not user:
                # Check if self-registration is allowed (use ConfigManager to respect database settings)
                allow_self_register = ConfigManager.get_setting("allow_self_register", Config.ALLOW_SELF_REGISTER)
                if allow_self_register and (include_local or auth_method == "none"):
                    # If password auth is required, validate password during self-registration
                    if requires_password:
                        if not password:
                            flash(_("Password is required to create an account."), "error")
                            return render_template("auth/login.html", **_login_template_vars())
                        if len(password) < 8:
                            flash(_("Password must be at least 8 characters long."), "error")
                            return render_template("auth/login.html", **_login_template_vars())

                    # Create new user, promote to admin if username is configured as admin
                    role_name = "admin" if username in admin_usernames else "user"
                    user = User(username=username, role=role_name)
                    # Apply company default for daily working hours (overtime)
                    try:
                        from app.models import Settings

                        settings = Settings.get_settings()
                        user.standard_hours_per_day = float(
                            getattr(settings, "default_daily_working_hours", 8.0) or 8.0
                        )
                    except Exception:
                        pass

                    # Assign role from the new Role system
                    from app.models import Role

                    role_obj = Role.query.filter_by(name=role_name).first()
                    if role_obj:
                        user.roles.append(role_obj)

                    # Set password if password auth is required
                    if requires_password and password:
                        user.set_password(password)
                    db.session.add(user)
                    if not safe_commit("self_register_user", {"username": username}):
                        current_app.logger.error("Self-registration failed for '%s' due to DB error", username)
                        flash(
                            _("Could not create your account due to a database error. Please try again later."), "error"
                        )
                        return render_template("auth/login.html", **_login_template_vars())
                    current_app.logger.info("Created new user '%s'", username)

                    # Track onboarding started for new user
                    track_onboarding_started(
                        user.id, {"auth_method": auth_method, "self_registered": True, "is_admin": role_name == "admin"}
                    )

                    flash(_("Welcome! Your account has been created."), "success")
                else:
                    log_event("auth.login_failed", username=username, reason="user_not_found", auth_method=auth_method)
                    if auth_method == "all" and include_ldap:
                        flash(_("Invalid username or password"), "error")
                    else:
                        flash(_("User not found. Please contact an administrator."), "error")
                    return render_template("auth/login.html", **_login_template_vars())
            else:
                # If existing user matches admin usernames, ensure admin role
                if username in admin_usernames and user.role != "admin":
                    user.role = "admin"
                    if not safe_commit("promote_admin_user", {"username": username}):
                        current_app.logger.error("Failed to promote '%s' to admin due to DB error", username)
                        flash(_("Could not update your account role due to a database error."), "error")
                        return render_template("auth/login.html", **_login_template_vars())

            # Check if user is active
            if not user.is_active:
                log_event("auth.login_failed", user_id=user.id, reason="account_disabled", auth_method=auth_method)
                flash(_("Account is disabled. Please contact an administrator."), "error")
                return render_template("auth/login.html", **_login_template_vars())

            if auth_method == "all" and include_ldap and getattr(user, "auth_provider", "local") == "ldap":
                from app.services.ldap_service import LDAPService

                if requires_password and not password:
                    log_event(
                        "auth.login_failed", user_id=user.id, reason="password_required", auth_method=auth_method
                    )
                    flash(_("Password is required"), "error")
                    return render_template("auth/login.html", **_login_template_vars())
                lu = LDAPService.authenticate(username, password)
                if lu:
                    return _finalize_login_after_verification(lu, log_auth_method="ldap")
                log_event("auth.login_failed", user_id=user.id, reason="ldap_auth_failed", auth_method=auth_method)
                flash(_("Invalid username or password"), "error")
                return render_template("auth/login.html", **_login_template_vars())

            # Handle password authentication based on mode
            if requires_password:
                # Password authentication is required
                if user.has_password:
                    # User has password set - verify it
                    if not password:
                        log_event(
                            "auth.login_failed", user_id=user.id, reason="password_required", auth_method=auth_method
                        )
                        flash(_("Password is required"), "error")
                        return render_template("auth/login.html", **_login_template_vars())

                    if not user.check_password(password):
                        log_event(
                            "auth.login_failed", user_id=user.id, reason="invalid_password", auth_method=auth_method
                        )
                        if auth_method == "all" and include_ldap:
                            from app.services.ldap_service import LDAPService

                            lu = LDAPService.authenticate(username, password)
                            if lu:
                                return _finalize_login_after_verification(lu, log_auth_method="ldap")
                        flash(_("Invalid username or password"), "error")
                        return render_template("auth/login.html", **_login_template_vars())
                else:
                    # User doesn't have password set - require password to be provided
                    if not password:
                        # No password provided - prompt user to set one
                        log_event(
                            "auth.login_failed", user_id=user.id, reason="no_password_set", auth_method=auth_method
                        )
                        flash(
                            _("No password is set for your account. Please enter a password to set one and log in."),
                            "error",
                        )
                        return render_template("auth/login.html", **_login_template_vars())

                    # Password provided - validate and set it
                    if len(password) < 8:
                        log_event(
                            "auth.login_failed", user_id=user.id, reason="password_too_short", auth_method=auth_method
                        )
                        flash(_("Password must be at least 8 characters long."), "error")
                        return render_template("auth/login.html", **_login_template_vars())

                    # Set the password and continue to login
                    user.set_password(password)
                    if not safe_commit("set_initial_password", {"user_id": user.id, "username": user.username}):
                        current_app.logger.error(
                            "Failed to set initial password for '%s' due to DB error", user.username
                        )
                        flash(_("Could not set password due to a database error. Please try again."), "error")
                        return render_template("auth/login.html", **_login_template_vars())
                    current_app.logger.info("User '%s' set initial password during login", user.username)
                    flash(_("Password has been set. You are now logged in."), "success")
            else:
                # requires_password=False (AUTH_METHOD='none') - allow login without password
                # This mode is for trusted environments only
                pass

            return _finalize_login_after_verification(user, log_auth_method=auth_method)
        except Exception as e:
            current_app.logger.exception("Login error: %s", e)
            flash(_("Unexpected error during login. Please try again or check server logs."), "error")
            return render_template("auth/login.html", **_login_template_vars())

    return render_template("auth/login.html", **_login_template_vars())


@auth_bp.route("/login/2fa", methods=["GET", "POST"])
@limiter.limit("10 per minute", methods=["POST"])
def two_factor():
    if current_user.is_authenticated:
        return redirect(url_for("main.dashboard"))

    user_id = session.get("pre_2fa_user_id")
    if not user_id:
        flash(_("Your login session expired. Please sign in again."), "error")
        return redirect(url_for("auth.login"))

    user = User.query.get(int(user_id))
    if not user or not user.is_active or not getattr(user, "two_factor_enabled", False):
        session.pop("pre_2fa_user_id", None)
        session.pop("pre_2fa_next", None)
        flash(_("Your login session expired. Please sign in again."), "error")
        return redirect(url_for("auth.login"))

    if request.method == "POST":
        code = (request.form.get("code") or "").strip().replace(" ", "")
        try:
            import pyotp

            totp = pyotp.TOTP(user.get_two_factor_secret())
            ok = bool(code) and totp.verify(code, valid_window=1)
        except Exception:
            ok = False

        if not ok:
            flash(_("Invalid authentication code."), "error")
            return render_template("auth/two_factor.html")

        # Success: finalize login
        session.pop("pre_2fa_user_id", None)
        next_page = session.pop("pre_2fa_next", None)
        login_user(user, remember=True)
        log_event("auth.login_2fa", user_id=user.id)

        if next_page and next_page.startswith("/"):
            return redirect(next_page)
        return redirect(url_for("main.dashboard"))

    return render_template("auth/two_factor.html")


@auth_bp.route("/profile/2fa", methods=["GET", "POST"])
@login_required
def two_factor_setup():
    """
    User self-service TOTP enrollment/disable.
    """
    if request.method == "POST":
        action = (request.form.get("action") or "").strip()
        code = (request.form.get("code") or "").strip().replace(" ", "")

        if action == "enable":
            # Ensure a secret exists
            if not (current_user.two_factor_secret or "").strip():
                import pyotp

                current_user.set_two_factor_secret(pyotp.random_base32())
                db.session.add(current_user)
                db.session.commit()

            try:
                import pyotp

                totp = pyotp.TOTP(current_user.get_two_factor_secret())
                ok = bool(code) and totp.verify(code, valid_window=1)
            except Exception:
                ok = False

            if not ok:
                flash(_("Invalid authentication code."), "error")
                return redirect(url_for("auth.two_factor_setup"))

            current_user.two_factor_enabled = True
            current_user.two_factor_confirmed_at = datetime.utcnow()
            try:
                db.session.add(current_user)
                db.session.commit()
                log_event("auth.2fa_enabled", user_id=current_user.id)
                flash(_("Two-factor authentication enabled."), "success")
            except Exception:
                db.session.rollback()
                flash(_("Could not enable two-factor authentication due to a database error."), "error")
            return redirect(url_for("auth.two_factor_setup"))

        if action == "disable":
            if not getattr(current_user, "two_factor_enabled", False):
                return redirect(url_for("auth.two_factor_setup"))

            try:
                import pyotp

                totp = pyotp.TOTP(current_user.get_two_factor_secret())
                ok = bool(code) and totp.verify(code, valid_window=1)
            except Exception:
                ok = False

            if not ok:
                flash(_("Invalid authentication code."), "error")
                return redirect(url_for("auth.two_factor_setup"))

            current_user.two_factor_enabled = False
            current_user.two_factor_confirmed_at = None
            current_user.two_factor_secret = None
            try:
                db.session.add(current_user)
                db.session.commit()
                log_event("auth.2fa_disabled", user_id=current_user.id)
                flash(_("Two-factor authentication disabled."), "success")
            except Exception:
                db.session.rollback()
                flash(_("Could not disable two-factor authentication due to a database error."), "error")
            return redirect(url_for("auth.two_factor_setup"))

    # Ensure there is a secret available for enrollment preview.
    secret = current_user.get_two_factor_secret()
    provisioning_uri = ""
    if secret:
        try:
            import pyotp

            provisioning_uri = pyotp.totp.TOTP(secret).provisioning_uri(
                name=current_user.username, issuer_name="TimeTracker"
            )
        except Exception:
            provisioning_uri = ""

    return render_template(
        "auth/two_factor_setup.html",
        two_factor_enabled=getattr(current_user, "two_factor_enabled", False),
        secret=secret,
        provisioning_uri=provisioning_uri,
    )


@auth_bp.route("/logout")
@login_required
def logout():
    """Logout the current user"""
    username = current_user.username
    user_id = current_user.id

    from app.telemetry.otel_setup import business_span

    with business_span("auth.logout", user_id=user_id):
        # Track logout event before logging out
        log_event("auth.logout", user_id=user_id)
        track_event(user_id, "auth.logout", {})

    # Try OIDC end-session if enabled and configured
    try:
        auth_method = normalize_auth_method(current_app.config.get("AUTH_METHOD", "local"))
    except Exception:
        auth_method = "local"

    # Backwards compatibility: older versions stored the full id_token in the cookie session.
    # Keep it for RP-initiated logout if present, but don't continue storing it.
    id_token = session.pop("oidc_id_token", None)

    # New approach: store only a small reference key in the cookie session and keep the
    # full id_token server-side (Redis/in-memory cache) to avoid oversized session cookies.
    id_token_key = session.pop("oidc_id_token_key", None)
    if id_token_key:
        try:
            cache = get_cache()
            cache_key = f"oidc:id_token:{id_token_key}"
            cached = cache.get(cache_key)
            # Prefer cached token when available; otherwise fall back to legacy value.
            if cached:
                id_token = cached
            # Best-effort cleanup: token should not linger after logout.
            cache.delete(cache_key)
        except Exception:
            pass
    logout_user()
    # Ensure both possible session keys are cleared for compatibility
    try:
        session.pop("_user_id", None)
        session.pop("user_id", None)
    except Exception:
        pass
    flash(_("Goodbye, %(username)s!", username=username), "info")

    if auth_includes_oidc(auth_method):
        # Only perform RP-Initiated Logout if OIDC_POST_LOGOUT_REDIRECT_URI is explicitly configured
        post_logout = getattr(Config, "OIDC_POST_LOGOUT_REDIRECT_URI", None)
        if post_logout:
            client = oauth.create_client("oidc")
            if client:
                try:
                    # Build end-session URL if provider supports it
                    metadata = client.load_server_metadata()
                    end_session_endpoint = metadata.get("end_session_endpoint") or metadata.get("revocation_endpoint")
                    if end_session_endpoint:
                        params = {}
                        if id_token:
                            params["id_token_hint"] = id_token
                        params["post_logout_redirect_uri"] = post_logout
                        from urllib.parse import urlencode

                        return redirect(f"{end_session_endpoint}?{urlencode(params)}")
                except Exception:
                    pass

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


@auth_bp.route("/profile")
@login_required
def profile():
    """User profile page"""
    return render_template("auth/profile.html")


@auth_bp.route("/profile/edit", methods=["GET", "POST"])
@login_required
def edit_profile():
    """Edit user profile"""
    # Get authentication method from Flask app config (reads from environment)
    try:
        auth_method = normalize_auth_method(current_app.config.get("AUTH_METHOD", "local"))
    except Exception:
        auth_method = "local"

    requires_password = requires_password_form(auth_method)
    allow_password_change = requires_password and getattr(current_user, "auth_provider", "local") != "ldap"

    if request.method == "POST":
        from app.utils.validation import sanitize_input

        # Update real name if provided
        full_name = sanitize_input(request.form.get("full_name", "").strip(), max_length=200)
        current_user.full_name = full_name or None
        # Update preferred language
        preferred_language = (request.form.get("preferred_language") or "").strip().lower()
        available = (current_app.config.get("LANGUAGES") or {}).keys()
        if preferred_language in available:
            current_user.preferred_language = preferred_language
            # Also set session so it applies immediately
            session["preferred_language"] = preferred_language

        # Handle password update if password auth is required (not LDAP-managed accounts)
        if allow_password_change:
            password = request.form.get("password", "").strip()
            password_confirm = request.form.get("password_confirm", "").strip()

            if password:
                # Validate password
                if len(password) < 8:
                    flash(_("Password must be at least 8 characters long."), "error")
                    return redirect(url_for("auth.edit_profile"))

                if password != password_confirm:
                    flash(_("Passwords do not match."), "error")
                    return redirect(url_for("auth.edit_profile"))

                # Set the new password
                current_user.set_password(password)
                current_app.logger.info("User '%s' updated password", current_user.username)

        # Handle avatar upload if provided
        try:
            file = request.files.get("avatar")
        except Exception:
            file = None

        if file and getattr(file, "filename", ""):
            filename = file.filename
            if not allowed_avatar_file(filename):
                flash(_("Invalid avatar file type. Allowed: PNG, JPG, JPEG, GIF, WEBP"), "error")
                return redirect(url_for("auth.edit_profile"))
            # Validate image content with Pillow
            try:
                from PIL import Image

                file.stream.seek(0)
                img = Image.open(file.stream)
                img.verify()
                file.stream.seek(0)
            except Exception:
                flash(_("Invalid image file."), "error")
                return redirect(url_for("auth.edit_profile"))

            # Generate unique filename and save
            import os
            import uuid

            ext = filename.rsplit(".", 1)[1].lower()
            unique_name = f"avatar_{current_user.id}_{uuid.uuid4().hex[:8]}.{ext}"
            folder = get_avatar_upload_folder()
            file_path = os.path.join(folder, unique_name)
            try:
                file.save(file_path)
            except Exception:
                flash(_("Failed to save avatar on server."), "error")
                return redirect(url_for("auth.edit_profile"))

            # Remove old avatar if exists
            try:
                old_filename = getattr(current_user, "avatar_filename", None)
                if old_filename:
                    old_path = os.path.join(folder, old_filename)
                    if os.path.exists(old_path):
                        try:
                            os.remove(old_path)
                        except OSError:
                            pass
            except Exception:
                pass

            current_user.avatar_filename = unique_name
        try:
            db.session.commit()
            flash(_("Profile updated successfully"), "success")
        except Exception:
            db.session.rollback()
            flash(_("Could not update your profile due to a database error."), "error")
        return redirect(url_for("auth.profile"))

    return render_template("auth/edit_profile.html", requires_password=allow_password_change)


@auth_bp.route("/change-password", methods=["GET", "POST"])
@login_required
def change_password():
    """Change password page - required when password_change_required is True"""
    if getattr(current_user, "auth_provider", "local") == "ldap":
        flash(_("Password cannot be changed here for your account."), "warning")
        return redirect(url_for("main.dashboard"))

    if request.method == "POST":
        current_password = request.form.get("current_password", "").strip()
        new_password = request.form.get("new_password", "").strip()
        confirm_password = request.form.get("confirm_password", "").strip()

        # Validate inputs
        if not new_password:
            flash(_("New password is required"), "error")
            return render_template("auth/change_password.html")

        if len(new_password) < 8:
            flash(_("Password must be at least 8 characters long."), "error")
            return render_template("auth/change_password.html")

        if new_password != confirm_password:
            flash(_("Passwords do not match."), "error")
            return render_template("auth/change_password.html")

        # If user has a password, verify current password
        if current_user.has_password:
            if not current_password:
                flash(_("Current password is required"), "error")
                return render_template("auth/change_password.html")

            if not current_user.check_password(current_password):
                flash(_("Current password is incorrect"), "error")
                return render_template("auth/change_password.html")

        # Set new password
        current_user.set_password(new_password)
        current_user.password_change_required = False

        try:
            db.session.commit()
            current_app.logger.info("User '%s' changed password", current_user.username)
            flash(_("Password changed successfully. You can now continue."), "success")
            return redirect(url_for("main.dashboard"))
        except Exception:
            db.session.rollback()
            flash(_("Could not update password due to a database error."), "error")
            return render_template("auth/change_password.html")

    return render_template("auth/change_password.html")


@auth_bp.route("/profile/avatar/remove", methods=["POST"])
@login_required
def remove_avatar():
    """Remove the current user's avatar file and clear the field."""
    try:
        import os

        folder = get_avatar_upload_folder()
        if current_user.avatar_filename:
            path = os.path.join(folder, current_user.avatar_filename)
            if os.path.exists(path):
                try:
                    os.remove(path)
                except OSError:
                    pass
        current_user.avatar_filename = None
        db.session.commit()
        flash(_("Avatar removed"), "success")
    except Exception:
        db.session.rollback()
        flash(_("Failed to remove avatar."), "error")
    return redirect(url_for("auth.edit_profile"))


# Public route to serve uploaded avatars from the static uploads directory
@auth_bp.route("/uploads/avatars/<path:filename>")
def serve_uploaded_avatar(filename):
    folder = get_avatar_upload_folder()
    return send_from_directory(folder, filename)


@auth_bp.route("/profile/theme", methods=["POST"])
@login_required
def update_theme_preference():
    """Persist user theme preference (light|dark|system)."""
    try:
        value = (request.json.get("theme") if request.is_json else request.form.get("theme") or "").strip().lower()
    except Exception:
        value = (request.form.get("theme") or "").strip().lower()

    if value not in ("light", "dark", "system"):
        return ({"error": "invalid theme value"}, 400)

    # Store None for system to allow fallback to system preference
    current_user.theme_preference = None if value == "system" else value
    try:
        db.session.commit()
    except Exception:
        db.session.rollback()
        return ({"error": "failed to save preference"}, 500)

    return ({"ok": True, "theme": value}, 200)


# --- OIDC placeholders (optional integration) ---
@auth_bp.route("/login/oidc")
def login_oidc():
    """Start OIDC login using Authlib."""
    if current_app.config.get("DEMO_MODE"):
        flash(
            _("Demo mode: only the demo account can be used. Please use the credentials on the login page."), "warning"
        )
        return redirect(url_for("auth.login"))

    try:
        auth_method = normalize_auth_method(current_app.config.get("AUTH_METHOD", "local"))
    except Exception:
        auth_method = "local"

    if not auth_includes_oidc(auth_method):
        return redirect(url_for("auth.login"))

    client = oauth.create_client("oidc")

    # If client doesn't exist, try lazy loading (for DNS resolution failures at startup)
    if not client:
        issuer = current_app.config.get("OIDC_ISSUER_FOR_LAZY_LOAD")
        client_id = current_app.config.get("OIDC_CLIENT_ID_FOR_LAZY_LOAD")
        client_secret = current_app.config.get("OIDC_CLIENT_SECRET_FOR_LAZY_LOAD")
        scopes = current_app.config.get("OIDC_SCOPES_FOR_LAZY_LOAD", "openid profile email")

        if issuer and client_id and client_secret:
            # Try to fetch metadata and register client now
            from app.utils.oidc_metadata import fetch_oidc_metadata

            max_retries = int(current_app.config.get("OIDC_METADATA_RETRY_ATTEMPTS", 3))
            retry_delay = int(current_app.config.get("OIDC_METADATA_RETRY_DELAY", 2))
            timeout = int(current_app.config.get("OIDC_METADATA_FETCH_TIMEOUT", 10))
            dns_strategy = current_app.config.get("OIDC_DNS_RESOLUTION_STRATEGY", "auto")
            use_ip_directly = current_app.config.get("OIDC_USE_IP_DIRECTLY", True)
            use_docker_internal = current_app.config.get("OIDC_USE_DOCKER_INTERNAL", True)

            current_app.logger.info("Attempting lazy OIDC client registration for issuer %s", issuer)

            metadata, metadata_error, diagnostics = fetch_oidc_metadata(
                issuer,
                max_retries=max_retries,
                retry_delay=retry_delay,
                timeout=timeout,
                use_dns_test=True,
                dns_strategy=dns_strategy,
                use_ip_directly=use_ip_directly,
                use_docker_internal=use_docker_internal,
            )

            if metadata:
                try:
                    oauth.register(
                        name="oidc",
                        client_id=client_id,
                        client_secret=client_secret,
                        server_metadata_url=f"{issuer.rstrip('/')}/.well-known/openid-configuration",
                        client_kwargs={
                            "scope": scopes,
                            "code_challenge_method": "S256",
                        },
                    )
                    current_app.logger.info(
                        "Successfully registered OIDC client via lazy loading for issuer %s", issuer
                    )
                    # Clear lazy load config since we succeeded
                    current_app.config.pop("OIDC_ISSUER_FOR_LAZY_LOAD", None)
                    current_app.config.pop("OIDC_CLIENT_ID_FOR_LAZY_LOAD", None)
                    current_app.config.pop("OIDC_CLIENT_SECRET_FOR_LAZY_LOAD", None)
                    current_app.config.pop("OIDC_SCOPES_FOR_LAZY_LOAD", None)
                    client = oauth.create_client("oidc")
                except Exception as e:
                    current_app.logger.error("Failed to register OIDC client during lazy loading: %s", e)
                    flash(
                        _(
                            "Failed to connect to Single Sign-On provider. Please contact an administrator. Error: %(error)s",
                            error=str(e),
                        ),
                        "error",
                    )
                    return redirect(url_for("auth.login"))
            else:
                # Still can't fetch metadata
                current_app.logger.error("Lazy OIDC metadata fetch failed: %s", metadata_error)
                flash(
                    _(
                        "Cannot connect to Single Sign-On provider. DNS resolution may be failing. "
                        "Please contact an administrator. Error: %(error)s",
                        error=metadata_error or "Unknown error",
                    ),
                    "error",
                )
                return redirect(url_for("auth.login"))
        else:
            flash(_("Single Sign-On is not configured yet. Please contact an administrator."), "warning")
            return redirect(url_for("auth.login"))

    # Check if client has metadata loaded (for cases where registration succeeded but metadata fetch failed)
    if client:
        try:
            # Try to access metadata - if it fails, attempt to load it
            if not hasattr(client, "metadata") or not client.metadata:
                issuer = current_app.config.get("OIDC_ISSUER") or current_app.config.get("OIDC_ISSUER_FOR_LAZY_LOAD")
                if issuer:
                    from app.utils.oidc_metadata import fetch_oidc_metadata

                    max_retries = int(current_app.config.get("OIDC_METADATA_RETRY_ATTEMPTS", 3))
                    retry_delay = int(current_app.config.get("OIDC_METADATA_RETRY_DELAY", 2))
                    timeout = int(current_app.config.get("OIDC_METADATA_FETCH_TIMEOUT", 10))
                    dns_strategy = current_app.config.get("OIDC_DNS_RESOLUTION_STRATEGY", "auto")
                    use_ip_directly = current_app.config.get("OIDC_USE_IP_DIRECTLY", True)
                    use_docker_internal = current_app.config.get("OIDC_USE_DOCKER_INTERNAL", True)

                    metadata, metadata_error, diagnostics = fetch_oidc_metadata(
                        issuer,
                        max_retries=max_retries,
                        retry_delay=retry_delay,
                        timeout=timeout,
                        use_dns_test=True,
                        dns_strategy=dns_strategy,
                        use_ip_directly=use_ip_directly,
                        use_docker_internal=use_docker_internal,
                    )

                    if metadata:
                        try:
                            # Load metadata into existing client
                            client.load_server_metadata()
                            current_app.logger.info("Successfully loaded OIDC metadata for existing client")
                        except Exception as e:
                            current_app.logger.warning("Failed to load metadata into existing client: %s", e)
        except Exception as e:
            current_app.logger.debug("Error checking client metadata: %s", e)

    if not client:
        flash(_("Single Sign-On is not configured yet. Please contact an administrator."), "warning")
        return redirect(url_for("auth.login"))

    # Preserve next redirect
    next_page = request.args.get("next")
    if next_page and next_page.startswith("/"):
        session["oidc_next"] = next_page

    # Determine redirect URI
    redirect_uri = getattr(Config, "OIDC_REDIRECT_URI", None) or url_for("auth.oidc_callback", _external=True)
    # Trigger authorization code flow (with PKCE via client_kwargs)
    return client.authorize_redirect(redirect_uri)


@auth_bp.route("/auth/oidc/callback")
def oidc_callback():
    """Handle OIDC callback: exchange code, map claims, upsert user, log them in."""
    client = oauth.create_client("oidc")
    if not client:
        current_app.logger.info("OIDC callback redirect to login: reason=no_oidc_client")
        flash(_("Single Sign-On is not configured."), "error")
        return redirect(url_for("auth.login"))

    try:
        # Exchange authorization code for tokens
        current_app.logger.info("OIDC callback: Starting token exchange")
        try:
            token = client.authorize_access_token()
        except Exception as token_err:
            err_str = str(token_err).lower()
            err_type_name = type(token_err).__name__
            is_algorithm_or_jwe = (
                "unsupported_algorithm" in err_str
                or "unsupportedalgorithmerror" in err_type_name.lower()
                or "jwe" in err_str
                or "authlib.jose" in (getattr(token_err, "__module__", "") or "")
            )
            if is_algorithm_or_jwe:
                current_app.logger.warning(
                    "OIDC token exchange failed: unsupported token algorithm or encrypted ID token (JWE). "
                    "IdP may have ID token encryption enabled: %s",
                    token_err,
                )
                current_app.logger.info("OIDC callback redirect to login: reason=unsupported_algorithm_or_jwe")
                flash(
                    _(
                        "SSO failed: encrypted or unsupported ID tokens. "
                        "Disable ID token encryption on your provider (e.g. in Authentik, leave the Encryption Key empty)."
                    ),
                    "error",
                )
            else:
                current_app.logger.warning(
                    "OIDC token exchange failed (state/code_verifier mismatch or invalid code). "
                    "Session may have been lost between redirect and callback – check cookie size, domain, Secure, SameSite and proxy headers: %s",
                    token_err,
                )
                current_app.logger.info("OIDC callback redirect to login: reason=token_exchange_failed")
                flash(_("SSO failed. If this repeats, check session cookie and proxy configuration."), "error")
            return redirect(url_for("auth.login"))

        current_app.logger.info(
            "OIDC callback: Token exchange successful, token keys: %s",
            list(token.keys()) if isinstance(token, dict) else "not-a-dict",
        )

        # Log raw token structure (mask sensitive data)
        if isinstance(token, dict):
            token_info = {
                k: (v[:20] + "..." if isinstance(v, str) and len(v) > 20 else v)
                for k, v in token.items()
                if k not in ["access_token", "id_token", "refresh_token"]
            }
            current_app.logger.debug("OIDC callback: Token info: %s", token_info)

        # Parse ID token claims
        claims = {}
        id_token_parsed = False
        try:
            current_app.logger.info("OIDC callback: Attempting to parse ID token")
            # Authlib already validates and parses the ID token during authorize_access_token()
            # The parsed claims should be available in the token dict under 'userinfo' key
            if isinstance(token, dict) and "userinfo" in token:
                claims = token.get("userinfo", {})
                id_token_parsed = True
                current_app.logger.info(
                    "OIDC callback: ID token claims available from token, claims keys: %s", list(claims.keys())
                )
            else:
                # If not available, parse it manually with nonce from session
                # Authlib stores the nonce in session during authorize_redirect()
                nonce = session.get("_oidc_authlib_nonce_")
                current_app.logger.debug("OIDC callback: Nonce from session: %s", "present" if nonce else "missing")
                parsed = client.parse_id_token(token, nonce=nonce)
                if parsed:
                    claims = parsed
                    id_token_parsed = True
                    current_app.logger.info(
                        "OIDC callback: ID token parsed successfully, claims keys: %s", list(claims.keys())
                    )
                else:
                    current_app.logger.warning("OIDC callback: parse_id_token returned None/empty")
        except Exception as e:
            current_app.logger.error("OIDC callback: Failed to parse ID token: %s - %s", type(e).__name__, str(e))
            # Try to decode the token manually to debug
            try:
                if isinstance(token, dict) and "id_token" in token:
                    import jwt

                    # Decode without verification to inspect claims (for debugging only)
                    unverified = jwt.decode(token["id_token"], options={"verify_signature": False})
                    current_app.logger.info("OIDC callback: Unverified ID token claims: %s", list(unverified.keys()))
                    current_app.logger.debug("OIDC callback: Unverified token content: %s", unverified)
            except Exception as decode_err:
                current_app.logger.error("OIDC callback: Could not decode ID token for debugging: %s", str(decode_err))

        # Fetch userinfo endpoint as fallback or supplement
        userinfo = {}
        userinfo_fetched = False
        try:
            current_app.logger.info("OIDC callback: Fetching userinfo endpoint")
            fetched = client.userinfo(token=token)
            if fetched:
                userinfo = fetched
                userinfo_fetched = True
                current_app.logger.info("OIDC callback: Userinfo fetched successfully, keys: %s", list(userinfo.keys()))
                # If ID token parsing failed but userinfo succeeded, use userinfo for critical fields
                if not id_token_parsed and userinfo:
                    current_app.logger.warning(
                        "OIDC callback: ID token parsing failed, using userinfo as primary source"
                    )
                    claims = userinfo
            else:
                current_app.logger.warning("OIDC callback: userinfo endpoint returned None/empty")
        except Exception as e:
            current_app.logger.error("OIDC callback: Failed to fetch userinfo: %s - %s", type(e).__name__, str(e))

        # Resolve fields from claims/userinfo
        issuer = (claims.get("iss") or userinfo.get("iss") or "").strip()
        sub = (claims.get("sub") or userinfo.get("sub") or "").strip()

        # Fallback: OIDC UserInfo often has sub but not iss (e.g. Authelia). Use configured issuer.
        if sub and not issuer:
            issuer = (getattr(Config, "OIDC_ISSUER", None) or "").strip()
        # Second fallback: get iss from id_token without verification when parsing failed
        if not issuer and isinstance(token, dict) and token.get("id_token"):
            try:
                import jwt

                unverified = jwt.decode(token["id_token"], options={"verify_signature": False})
                if unverified.get("iss"):
                    issuer = (unverified.get("iss") or "").strip()
            except Exception:
                pass

        username_claim = getattr(Config, "OIDC_USERNAME_CLAIM", "preferred_username")
        full_name_claim = getattr(Config, "OIDC_FULL_NAME_CLAIM", "name")
        email_claim = getattr(Config, "OIDC_EMAIL_CLAIM", "email")
        groups_claim = getattr(Config, "OIDC_GROUPS_CLAIM", "groups")

        current_app.logger.info(
            "OIDC callback: Looking for claims - username:%s, email:%s, full_name:%s, groups:%s",
            username_claim,
            email_claim,
            full_name_claim,
            groups_claim,
        )

        username = (claims.get(username_claim) or userinfo.get(username_claim) or "").strip().lower()
        email = claims.get(email_claim) or userinfo.get(email_claim) or None
        if email:
            email = email.strip().lower()
        full_name = claims.get(full_name_claim) or userinfo.get(full_name_claim) or None
        if isinstance(full_name, str):
            full_name = full_name.strip()

        groups = userinfo.get(groups_claim) or claims.get(groups_claim) or []
        if isinstance(groups, str):
            groups = [groups]

        current_app.logger.info(
            "OIDC callback: Extracted values - issuer:%s, sub:%s, username:%s, email:%s, groups:%s",
            issuer[:30] if issuer else "empty",
            sub[:20] if sub else "empty",
            username or "empty",
            email or "empty",
            len(groups) if isinstance(groups, list) else "not-list",
        )

        if not issuer or not sub:
            current_app.logger.info("OIDC callback redirect to login: reason=missing_issuer_sub")
            current_app.logger.error(
                "OIDC callback missing issuer/sub - issuer:'%s' sub:'%s' - ID token parsed:%s, userinfo fetched:%s, claims keys:%s, userinfo keys:%s",
                issuer,
                sub,
                id_token_parsed,
                userinfo_fetched,
                list(claims.keys()),
                list(userinfo.keys()),
            )
            flash(
                _("Authentication failed: missing issuer or subject claim. Please check OIDC configuration."), "error"
            )
            return redirect(url_for("auth.login"))

        # Determine a fallback username if not provided
        if not username:
            if email and "@" in email:
                username = email.split("@", 1)[0]
            else:
                username = f"user-{sub[-8:]}"

        # Find or create user
        user = User.query.filter_by(oidc_issuer=issuer, oidc_sub=sub).first()

        if not user and email:
            # Attempt match by email
            user = User.query.filter_by(email=email).first()

        if not user:
            # Attempt match by username
            user = User.query.filter_by(username=username).first()

        if not user:
            # Demo mode: do not create users via OIDC
            if current_app.config.get("DEMO_MODE"):
                current_app.logger.info("OIDC callback redirect to login: reason=demo_mode_no_oidc_create")
                flash(
                    _("Demo mode: only the demo account can be used. Please use the credentials on the login page."),
                    "error",
                )
                return redirect(url_for("auth.login"))
            # Create if allowed (use ConfigManager to respect database settings)
            allow_self_register = ConfigManager.get_setting("allow_self_register", Config.ALLOW_SELF_REGISTER)
            if not allow_self_register:
                current_app.logger.info("OIDC callback redirect to login: reason=self_registration_disabled")
                flash(_("User account does not exist and self-registration is disabled."), "error")
                return redirect(url_for("auth.login"))
            role_name = "user"
            try:
                user = User(username=username, role=role_name, email=email, full_name=full_name)
                user.is_active = True
                user.oidc_issuer = issuer
                user.oidc_sub = sub
                user.auth_provider = "oidc"
                # Apply company default for daily working hours (overtime)
                try:
                    from app.models import Settings

                    settings = Settings.get_settings()
                    user.standard_hours_per_day = float(getattr(settings, "default_daily_working_hours", 8.0) or 8.0)
                except Exception:
                    pass

                # Assign role from the new Role system
                from app.models import Role

                role_obj = Role.query.filter_by(name=role_name).first()
                if role_obj:
                    user.roles.append(role_obj)

                db.session.add(user)
                if not safe_commit("oidc_create_user", {"username": username, "email": email}):
                    raise RuntimeError("db commit failed on user create")

                # Track onboarding started for new OIDC user
                track_onboarding_started(
                    user.id,
                    {
                        "auth_method": "oidc",
                        "self_registered": True,
                        "is_admin": role_name == "admin",
                        "has_email": bool(email),
                    },
                )

                flash(_("Welcome! Your account has been created."), "success")
            except Exception as e:
                current_app.logger.exception("Failed to create user from OIDC claims: %s", e)
                current_app.logger.info("OIDC callback redirect to login: reason=db_create_user_failed")
                flash(_("Could not create your account due to a database error."), "error")
                return redirect(url_for("auth.login"))
        else:
            # Update linkage and profile fields
            changed = False
            if getattr(user, "auth_provider", "local") != "oidc":
                user.auth_provider = "oidc"
                changed = True
            if not user.oidc_issuer or not user.oidc_sub:
                user.oidc_issuer = issuer
                user.oidc_sub = sub
                changed = True
            # Update profile fields when provided
            if email and user.email != email:
                user.email = email
                changed = True
            if full_name and user.full_name != full_name:
                user.full_name = full_name
                changed = True
            if changed:
                if not safe_commit("oidc_update_user", {"user_id": user.id}):
                    current_app.logger.warning("DB commit failed updating user from OIDC; continuing")

        # Admin role mapping based on configured group or emails
        try:
            admin_set = False
            admin_group = getattr(Config, "OIDC_ADMIN_GROUP", None)
            admin_emails = getattr(Config, "OIDC_ADMIN_EMAILS", []) or []
            if admin_group and isinstance(groups, (list, tuple)) and admin_group in groups and user.role != "admin":
                user.role = "admin"
                admin_set = True
            if email and email in [e.strip().lower() for e in admin_emails] and user.role != "admin":
                user.role = "admin"
                admin_set = True
            if admin_set:
                if not safe_commit("oidc_promote_admin", {"user_id": user.id}):
                    current_app.logger.warning("DB commit failed promoting user to admin from OIDC; continuing")
        except Exception:
            pass

        # OIDC group -> RBAC Role mapping (additive by default).
        # When OIDC_ROLE_GROUP_MAP is configured, sync user.roles based on the
        # groups claim. Default mode is "additive" — only adds roles that match
        # the user's groups; never revokes. To enable revocation of roles
        # mapped to groups the user no longer belongs to, set
        # OIDC_ROLE_SYNC_MODE=sync. The OIDC_NEVER_REVOKE_USER_IDS escape
        # hatch keeps specific user IDs immune to revocation regardless of mode.
        try:
            role_map = getattr(Config, "OIDC_ROLE_GROUP_MAP", {}) or {}
            if role_map and isinstance(groups, (list, tuple)):
                from app.models import Role

                sync_mode = getattr(Config, "OIDC_ROLE_SYNC_MODE", "additive")
                never_revoke = getattr(Config, "OIDC_NEVER_REVOKE_USER_IDS", set()) or set()

                # Compute target Role names from the user's groups
                target_role_names = {role_map[g] for g in groups if g in role_map}

                # Look up Role objects by name (only those that actually exist in DB)
                target_roles = (
                    Role.query.filter(Role.name.in_(target_role_names)).all() if target_role_names else []
                )
                target_role_set = set(target_roles)
                current_role_set = set(user.roles)
                roles_changed = False

                # ADD: any target roles the user doesn't already have
                for r in target_role_set - current_role_set:
                    user.roles.append(r)
                    roles_changed = True
                    current_app.logger.info(
                        "OIDC role sync: added role %s to user %s (matched group)", r.name, user.username
                    )

                # REMOVE: only in sync mode AND only mapped roles (never revoke roles
                # assigned manually outside OIDC scope) AND only if user not protected.
                if sync_mode == "sync" and user.id not in never_revoke:
                    mapped_role_names = set(role_map.values())
                    for r in current_role_set - target_role_set:
                        if r.name in mapped_role_names:
                            user.roles.remove(r)
                            roles_changed = True
                            current_app.logger.info(
                                "OIDC role sync: removed role %s from user %s (no matching group)",
                                r.name,
                                user.username,
                            )

                if roles_changed:
                    if not safe_commit("oidc_sync_roles", {"user_id": user.id}):
                        current_app.logger.warning(
                            "DB commit failed syncing OIDC roles for user_id=%s", user.id
                        )
            elif getattr(Config, "_oidc_role_map_raw", None):
                # User configured the env var but it failed to parse; surface once per login.
                current_app.logger.warning(
                    "OIDC_ROLE_GROUP_MAP is set but failed to parse as a JSON object; role sync disabled."
                )
        except Exception as e:
            current_app.logger.exception(
                "OIDC role sync failed for user_id=%s: %s", getattr(user, "id", None), e
            )

        # Check if user is active
        if not user.is_active:
            current_app.logger.info("OIDC callback redirect to login: reason=user_inactive")
            flash(_("Account is disabled. Please contact an administrator."), "error")
            return redirect(url_for("auth.login"))

        # Persist id_token for possible end-session
        try:
            if isinstance(token, dict) and token.get("id_token"):
                # IMPORTANT: Don't store the full id_token in the cookie session.
                # It can be large (e.g., groups claim), which can overflow cookie limits
                # and cause login loops (session dropped/truncated by the browser).
                import secrets

                id_token = token.get("id_token")
                key = secrets.token_urlsafe(24)
                cache_key = f"oidc:id_token:{key}"
                try:
                    ttl = int(
                        getattr(current_app.config.get("PERMANENT_SESSION_LIFETIME"), "total_seconds", lambda: 86400)()
                    )
                except Exception:
                    ttl = 86400

                cache = get_cache()
                cache.set(cache_key, id_token, ttl=ttl)
                session["oidc_id_token_key"] = key
                # Backwards compatibility cleanup
                session.pop("oidc_id_token", None)
        except Exception:
            pass

        from app.telemetry.otel_setup import business_span

        with business_span("auth.login", user_id=user.id, auth_method="oidc"):
            login_user(user, remember=True)

            # Auto-migrate user from legacy role to new role system if needed
            if not user.roles and user.role:
                from app.utils.role_migration import migrate_single_user

                if migrate_single_user(user.id):
                    current_app.logger.info(
                        "Auto-migrated OIDC user '%s' from legacy role '%s' to new role system",
                        user.username,
                        user.role,
                    )

            try:
                user.update_last_login()
            except Exception:
                pass

            # Track successful OIDC login (log_event is fast, track_event is deferred to avoid blocking)
            log_event("auth.login", user_id=user.id, auth_method="oidc")
        # Defer track_event to avoid blocking redirect - PostHog calls can be slow/timeout
        import threading

        def track_login_async():
            try:
                track_event(user.id, "auth.login", {"auth_method": "oidc"})
            except Exception:
                pass  # Don't let analytics errors affect login

        threading.Thread(target=track_login_async, daemon=True).start()

        # Note: identify_user_with_segments and set_super_properties are deferred to dashboard
        # to avoid blocking the OIDC redirect. The dashboard calls update_user_segments_if_needed
        # which has caching logic and will handle this efficiently.

        # Redirect to intended page or dashboard
        next_page = session.pop("oidc_next", None) or request.args.get("next")
        if not next_page or not next_page.startswith("/"):
            next_page = url_for("main.dashboard")
        flash(_("Welcome back, %(username)s!", username=user.username), "success")
        return redirect(next_page)

    except Exception as e:
        current_app.logger.exception("OIDC callback error: %s", e)
        current_app.logger.info("OIDC callback redirect to login: reason=exception")
        flash(_("Unexpected error during SSO login. Please try again or contact support."), "error")
        return redirect(url_for("auth.login"))
