use crate::{
    middleware::{
        check_credentials_with_multi_identity_provider,
        parse_issuer_from_request,
    },
    rest::shared::{build_actor_context, UrlGroup},
    settings::ADMIN_API_SCOPE,
};

use actix_web::{get, post, web, HttpRequest, HttpResponse, Responder};
use chrono::Duration;
use myc_core::{
    domain::{
        actors::SystemActor,
        dtos::user::{Totp, User},
        entities::TokenInvalidation,
    },
    models::AccountLifeCycle,
    settings::TEMPLATES,
    use_cases::role_scoped::beginner::user::{
        check_email_password_validity, check_token_and_activate_user,
        check_token_and_reset_password, create_default_user,
        request_magic_link, start_password_redefinition, totp_check_token,
        totp_disable, totp_finish_activation, totp_start_activation,
        verify_magic_link,
    },
};
use myc_diesel::repositories::SqlAppModule;
use myc_http_tools::{
    functions::encode_jwt, models::internal_auth_config::InternalOauthConfig,
    responses::GatewayError, utils::HttpJsonResponse,
    wrappers::default_response_to_http_response::handle_mapped_error, Email,
};
use mycelium_base::entities::FetchResponseKind;
use reqwest::StatusCode;
use serde::{Deserialize, Serialize, Serializer};
use serde_json::json;
use shaku::HasComponent;
use tera::Context as TeraContext;
use tracing::warn;
use utoipa::{IntoParams, ToResponse, ToSchema};

// ? ---------------------------------------------------------------------------
// ? Configure application
// ? ---------------------------------------------------------------------------

pub fn configure(config: &mut web::ServiceConfig) {
    config
        .service(check_email_registration_status_url)
        .service(create_default_user_url)
        .service(check_user_token_url)
        .service(start_password_redefinition_url)
        .service(check_token_and_reset_password_url)
        .service(check_email_password_validity_url)
        .service(totp_start_activation_url)
        .service(totp_finish_activation_url)
        .service(totp_check_token_url)
        .service(totp_disable_url)
        .service(request_magic_link_url)
        .service(display_magic_link_url)
        .service(verify_magic_link_url);
}

// ? ---------------------------------------------------------------------------
// ? Define API structs
// ? ---------------------------------------------------------------------------

#[derive(Serialize, Deserialize, ToResponse, ToSchema)]
#[serde(rename_all = "camelCase")]
pub struct CheckEmailStatusResponse {
    email: String,
    status: String,
    provider: Option<String>,
    has_account: bool,
}

fn serialize_duration<S>(
    duration: &Duration,
    serializer: S,
) -> Result<S::Ok, S::Error>
where
    S: Serializer,
{
    serializer.serialize_u64(duration.num_seconds() as u64)
}

#[derive(Serialize, ToResponse, ToSchema)]
#[serde(rename_all = "camelCase")]
pub struct MyceliumLoginResponse {
    token: String,
    #[serde(serialize_with = "serialize_duration")]
    duration: Duration,
    totp_required: bool,

    #[serde(flatten)]
    user: User,
}

#[derive(Deserialize, ToSchema, IntoParams)]
#[serde(rename_all = "camelCase")]
pub struct TotpActivationStartedParams {
    qr_code: Option<bool>,
}

#[derive(Serialize, ToResponse, ToSchema)]
#[serde(rename_all = "camelCase")]
pub struct TotpActivationStartedResponse {
    totp_url: Option<String>,
}

#[derive(Serialize, ToResponse, ToSchema)]
#[serde(rename_all = "camelCase")]
pub struct TotpActivationFinishedResponse {
    finished: bool,
}

#[derive(Deserialize, ToSchema)]
#[serde(rename_all = "camelCase")]
pub struct TotpUpdatingValidationBody {
    token: String,
}

#[derive(Deserialize, ToSchema)]
#[serde(rename_all = "camelCase")]
pub struct MagicLinkRequestBody {
    email: String,
}

#[derive(Serialize, ToResponse, ToSchema)]
#[serde(rename_all = "camelCase")]
pub struct MagicLinkRequestResponse {
    sent: bool,
}

#[derive(Deserialize, IntoParams, ToSchema)]
#[serde(rename_all = "camelCase")]
pub struct MagicLinkDisplayParams {
    token: String,
    email: String,
}

#[derive(Deserialize, ToSchema)]
#[serde(rename_all = "camelCase")]
pub struct MagicLinkVerifyBody {
    email: String,
    code: String,
}

#[derive(Deserialize, ToSchema)]
#[serde(rename_all = "camelCase")]
pub struct CreateDefaultUserBody {
    email: String,
    first_name: Option<String>,
    last_name: Option<String>,
    password: Option<String>,
}

#[derive(Deserialize, ToSchema)]
#[serde(rename_all = "camelCase")]
pub struct CheckTokenBody {
    token: String,
    email: String,
}

#[derive(Deserialize, ToSchema)]
#[serde(rename_all = "camelCase")]
pub struct StartPasswordResetBody {
    email: String,
}

#[derive(Deserialize, ToSchema)]
#[serde(rename_all = "camelCase")]
pub struct ResetPasswordBody {
    token: String,
    email: String,
    new_password: String,
}

#[derive(Deserialize, ToSchema)]
#[serde(rename_all = "camelCase")]
pub struct CheckUserCredentialsBody {
    email: String,
    password: String,
}

// ? ---------------------------------------------------------------------------
// ? Define API paths
//
// User
//
// ? ---------------------------------------------------------------------------

/// DEPRECATED: Check email status
///
/// This endpoint is deprecated. Please use the /status endpoint instead.
///
///
#[utoipa::path(
    get,
    operation_id = "check_email_registration_status",
    responses(
        (
            status = 410,
            description = "This endpoint is deprecated. Please use the /status endpoint instead.",
            body = HttpJsonResponse,
        ),
    ),
    security(()),
)]
#[get("/status")]
pub async fn check_email_registration_status_url() -> impl Responder {
    HttpResponse::Gone().json(HttpJsonResponse::new_message(
        "This endpoint is deprecated. Please use the /status endpoint instead.",
    ))
}

/// Register user
///
/// This route should be used to register a new user. If the Bearer token is
/// included in the request, the user will be registered with the provider
/// informed in the token. Otherwise, the password is required.
///
#[utoipa::path(
    post,
    operation_id = "create_default_user",
    params(
        (
            "Authorization" = Option<String>,
            Header,
            description = "An optional Bearer token. When included, the user \
            will be registered with the provider informed in the token.",
        )
    ),
    request_body = CreateDefaultUserBody,
    responses(
        (
            status = 500,
            description = "Unknown internal server error.",
            body = HttpJsonResponse,
        ),
        (
            status = 403,
            description = "Forbidden.",
            body = HttpJsonResponse,
        ),
        (
            status = 401,
            description = "Unauthorized.",
            body = HttpJsonResponse,
        ),
        (
            status = 201,
            description = "User successfully created.",
            body = User,
        ),
    ),
    security(()),
)]
#[post("")]
pub async fn create_default_user_url(
    req: HttpRequest,
    body: web::Json<CreateDefaultUserBody>,
    life_cycle_settings: web::Data<AccountLifeCycle>,
    sql_app_module: web::Data<SqlAppModule>,
) -> impl Responder {
    let provider = match parse_issuer_from_request(req.clone()).await {
        Err(err) => match err {
            GatewayError::Unauthorized(_) => None,
            _ => {
                warn!("Invalid issuer: {err}");

                return HttpResponse::BadRequest().json(
                    HttpJsonResponse::new_message(
                        "Invalid issuer.".to_string(),
                    ),
                );
            }
        },
        Ok((issuer, _)) => Some(issuer),
    };

    match create_default_user(
        body.email.to_owned(),
        body.first_name.to_owned(),
        body.last_name.to_owned(),
        body.password.to_owned(),
        provider,
        life_cycle_settings.get_ref().to_owned(),
        Box::new(&*sql_app_module.resolve_ref()),
        Box::new(&*sql_app_module.resolve_ref()),
        Box::new(&*sql_app_module.resolve_ref()),
        Box::new(&*sql_app_module.resolve_ref()),
        Box::new(&*sql_app_module.resolve_ref()),
    )
    .await
    {
        Ok(_) => HttpResponse::Created()
            .json(json!({"message": "User created successfully"})),
        Err(err) => handle_mapped_error(err),
    }
}

/// Check token and activate user
///
/// This route should be used to check the token and activate the user.
///
#[utoipa::path(
    post,
    operation_id = "check_user_token",
    request_body = CheckTokenBody,
    responses(
        (
            status = 500,
            description = "Unknown internal server error.",
            body = HttpJsonResponse,
        ),
        (
            status = 403,
            description = "Forbidden.",
            body = HttpJsonResponse,
        ),
        (
            status = 401,
            description = "Unauthorized.",
            body = HttpJsonResponse,
        ),
        (
            status = 200,
            description = "Activation token is valid.",
            body = bool,
        ),
    ),
    security(()),
)]
#[post("/validate-activation-token")]
pub async fn check_user_token_url(
    body: web::Json<CheckTokenBody>,
    app_module: web::Data<SqlAppModule>,
) -> impl Responder {
    let email = match Email::from_string(body.email.to_owned()) {
        Err(err) => {
            warn!("Invalid email: {}", err);
            return HttpResponse::BadRequest().json(
                HttpJsonResponse::new_message(
                    "Invalid email address.".to_string(),
                ),
            );
        }
        Ok(email) => email,
    };

    match check_token_and_activate_user(
        body.token.to_owned(),
        email,
        Box::new(&*app_module.resolve_ref()),
        Box::new(&*app_module.resolve_ref()),
        Box::new(&*app_module.resolve_ref()),
    )
    .await
    {
        Ok(_) => HttpResponse::Ok().finish(),
        Err(err) => handle_mapped_error(err),
    }
}

/// Start password redefinition
///
/// This route should be used to start the password redefinition process.
///
#[utoipa::path(
    post,
    operation_id = "start_password_redefinition",
    request_body = StartPasswordResetBody,
    responses(
        (
            status = 500,
            description = "Unknown internal server error.",
            body = HttpJsonResponse,
        ),
        (
            status = 403,
            description = "Forbidden.",
            body = HttpJsonResponse,
        ),
        (
            status = 401,
            description = "Unauthorized.",
            body = HttpJsonResponse,
        ),
        (
            status = 200,
            description = "Password change requested.",
            body = bool,
        ),
    ),
    security(()),
)]
#[post("/start-password-reset")]
pub async fn start_password_redefinition_url(
    body: web::Json<StartPasswordResetBody>,
    life_cycle_settings: web::Data<AccountLifeCycle>,
    sql_app_module: web::Data<SqlAppModule>,
) -> impl Responder {
    let email = match Email::from_string(body.email.to_owned()) {
        Err(err) => {
            warn!("Invalid email: {}", err);
            return HttpResponse::BadRequest().json(
                HttpJsonResponse::new_message(
                    "Invalid email address.".to_string(),
                ),
            );
        }
        Ok(email) => email,
    };

    match start_password_redefinition(
        email,
        life_cycle_settings.get_ref().to_owned(),
        Box::new(&*sql_app_module.resolve_ref()),
        Box::new(&*sql_app_module.resolve_ref()),
        Box::new(&*sql_app_module.resolve_ref()),
        Box::new(&*sql_app_module.resolve_ref()),
    )
    .await
    {
        Ok(_) => HttpResponse::Ok().json(true),
        Err(err) => handle_mapped_error(err),
    }
}

/// Check token and reset password
///
/// This route should be used to check the token and reset the password.
///
#[utoipa::path(
    post,
    operation_id = "check_token_and_reset_password",
    request_body = ResetPasswordBody,
    responses(
        (
            status = 500,
            description = "Unknown internal server error.",
            body = HttpJsonResponse,
        ),
        (
            status = 403,
            description = "Forbidden.",
            body = HttpJsonResponse,
        ),
        (
            status = 401,
            description = "Unauthorized.",
            body = HttpJsonResponse,
        ),
        (
            status = 200,
            description = "Password change requested.",
            body = bool,
        ),
    ),
    security(()),
)]
#[post("/reset-password")]
pub async fn check_token_and_reset_password_url(
    body: web::Json<ResetPasswordBody>,
    life_cycle_settings: web::Data<AccountLifeCycle>,
    sql_app_module: web::Data<SqlAppModule>,
) -> impl Responder {
    let email = match Email::from_string(body.email.to_owned()) {
        Err(err) => {
            warn!("Invalid email: {}", err);
            return HttpResponse::BadRequest().json(
                HttpJsonResponse::new_message(
                    "Invalid email address.".to_string(),
                ),
            );
        }
        Ok(email) => email,
    };

    match check_token_and_reset_password(
        body.token.to_owned(),
        email,
        body.new_password.to_owned(),
        life_cycle_settings.get_ref().to_owned(),
        Box::new(&*sql_app_module.resolve_ref()),
        Box::new(&*sql_app_module.resolve_ref()),
        Box::new(&*sql_app_module.resolve_ref()),
        Box::new(&*sql_app_module.resolve_ref()),
        Box::new(&*sql_app_module.resolve_ref()),
    )
    .await
    {
        Ok(_) => HttpResponse::Ok().json(true),
        Err(err) => handle_mapped_error(err),
    }
}

/// Login with email and password
///
/// This route should be used to login with email and password. If the user has
/// enabled the TOTP app, the user will be redirected to the TOTP activation
/// route.
///
#[utoipa::path(
    post,
    operation_id = "login_with_email_and_password",
    request_body = CheckUserCredentialsBody,
    responses(
        (
            status = 500,
            description = "Unknown internal server error.",
            body = HttpJsonResponse,
        ),
        (
            status = 403,
            description = "Forbidden.",
            body = HttpJsonResponse,
        ),
        (
            status = 401,
            description = "Unauthorized.",
            body = HttpJsonResponse,
        ),
        (
            status = 200,
            description = "Credentials are valid.",
            body = MyceliumLoginResponse,
        ),
    ),
    security(()),
)]
#[post("/login")]
pub async fn check_email_password_validity_url(
    body: web::Json<CheckUserCredentialsBody>,
    app_module: web::Data<SqlAppModule>,
    auth_config: web::Data<InternalOauthConfig>,
    core_config: web::Data<AccountLifeCycle>,
) -> impl Responder {
    let email_instance = match Email::from_string(body.email.to_owned()) {
        Err(err) => {
            warn!("Invalid email: {}", err);
            return HttpResponse::BadRequest().json(
                HttpJsonResponse::new_message(
                    "Invalid email address.".to_string(),
                ),
            );
        }
        Ok(email) => email,
    };

    match check_email_password_validity(
        email_instance,
        body.password.to_owned(),
        Box::new(&*app_module.resolve_ref()),
    )
    .await
    {
        Err(err) => handle_mapped_error(err),
        Ok((valid, user)) => match valid {
            true => {
                let _user = if let Some(user) = user {
                    user
                } else {
                    return HttpResponse::NoContent().finish();
                };

                match _user.mfa().totp {
                    //
                    // If TOTP is disabled, we can proceed with the login
                    // process without any further checks.
                    //
                    Totp::Disabled | Totp::Unknown => match encode_jwt(
                        _user.to_owned(),
                        auth_config.get_ref().to_owned(),
                        core_config.get_ref().to_owned(),
                        false,
                    )
                    .await
                    {
                        Err(err) => return err,
                        Ok((token, duration)) => {
                            return HttpResponse::Ok().json(
                                MyceliumLoginResponse {
                                    token,
                                    duration,
                                    totp_required: false,
                                    user: _user,
                                },
                            )
                        }
                    },
                    //
                    // If TOTP is enabled, we need to check if the user has
                    // already verified the TOTP app.
                    //
                    Totp::Enabled { verified, .. } => {
                        if !verified {
                            //
                            // Redirect user to TOTP activation
                            //
                            return HttpResponse::TemporaryRedirect()
                                .append_header((
                                    "Location",
                                    format!(
                                        "{}/totp/enable",
                                        build_actor_context(
                                            SystemActor::Beginner,
                                            UrlGroup::Users
                                        )
                                    ),
                                ))
                                .finish();
                        }

                        match encode_jwt(
                            _user.to_owned(),
                            auth_config.get_ref().to_owned(),
                            core_config.get_ref().to_owned(),
                            true,
                        )
                        .await
                        {
                            Err(err) => return err,
                            Ok((token, duration)) => {
                                return HttpResponse::Ok().json(
                                    MyceliumLoginResponse {
                                        token,
                                        duration,
                                        totp_required: true,
                                        user: _user,
                                    },
                                )
                            }
                        }
                    }
                }
            }
            false => HttpResponse::Unauthorized().finish(),
        },
    }
}

/// Enable TOTP
///
/// This route should be used to enable the TOTP app. Before enabling the TOTP
/// the user must be authenticated using the `/login/` route.
///
#[utoipa::path(
    post,
    operation_id = "totp_start_activation",
    params(TotpActivationStartedParams),
    responses(
        (
            status = 500,
            description = "Unknown internal server error.",
            body = HttpJsonResponse,
        ),
        (
            status = 403,
            description = "Forbidden.",
            body = HttpJsonResponse,
        ),
        (
            status = 401,
            description = "Unauthorized.",
            body = HttpJsonResponse,
        ),
        (
            status = 200,
            description = "Totp Activation Started.",
            body = TotpActivationStartedResponse,
        ),
        (
            status = 200,
            description = "Totp Activation Started.",
            body = String,
        ),
    ),
)]
#[post("/totp/enable")]
pub async fn totp_start_activation_url(
    req: HttpRequest,
    query: web::Query<TotpActivationStartedParams>,
    life_cycle_settings: web::Data<AccountLifeCycle>,
    sql_app_module: web::Data<SqlAppModule>,
) -> impl Responder {
    let (email, _) =
        match check_credentials_with_multi_identity_provider(req).await {
            Err(err) => {
                warn!("err: {:?}", err);
                return HttpResponse::InternalServerError()
                    .json(HttpJsonResponse::new_message(err));
            }
            Ok(res) => res,
        };

    let as_qr_code = query.qr_code.to_owned().unwrap_or(false);

    match totp_start_activation(
        email,
        query.qr_code,
        life_cycle_settings.get_ref().to_owned(),
        Box::new(&*sql_app_module.resolve_ref()),
        Box::new(&*sql_app_module.resolve_ref()),
        Box::new(&*sql_app_module.resolve_ref()),
        Box::new(&*sql_app_module.resolve_ref()),
    )
    .await
    {
        Ok((totp_url, qr_code)) => {
            if as_qr_code && qr_code.is_some() {
                return HttpResponse::build(StatusCode::OK)
                    .content_type("image/jpeg")
                    .body(qr_code.unwrap());
            };

            HttpResponse::Ok().json(TotpActivationStartedResponse { totp_url })
        }
        Err(err) => handle_mapped_error(err),
    }
}

/// Validate TOTP app
///
/// This route should be used to validate the TOTP app after enabling it.
///
#[utoipa::path(
    post,
    operation_id = "totp_finish_activation",
    request_body = TotpUpdatingValidationBody,
    responses(
        (
            status = 500,
            description = "Unknown internal server error.",
            body = HttpJsonResponse,
        ),
        (
            status = 403,
            description = "Forbidden.",
            body = HttpJsonResponse,
        ),
        (
            status = 401,
            description = "Unauthorized.",
            body = HttpJsonResponse,
        ),
        (
            status = 200,
            description = "Credentials are valid.",
            body = MyceliumLoginResponse,
        ),
    ),
)]
#[post("/totp/validate-app")]
pub async fn totp_finish_activation_url(
    req: HttpRequest,
    body: web::Json<TotpUpdatingValidationBody>,
    life_cycle_settings: web::Data<AccountLifeCycle>,
    sql_app_module: web::Data<SqlAppModule>,
) -> impl Responder {
    let (email, _) =
        match check_credentials_with_multi_identity_provider(req).await {
            Err(err) => {
                warn!("err: {:?}", err);
                return HttpResponse::InternalServerError()
                    .json(HttpJsonResponse::new_message(err));
            }
            Ok(res) => res,
        };

    match totp_finish_activation(
        email,
        body.token.to_owned(),
        life_cycle_settings.get_ref().to_owned(),
        Box::new(&*sql_app_module.resolve_ref()),
        Box::new(&*sql_app_module.resolve_ref()),
        Box::new(&*sql_app_module.resolve_ref()),
        Box::new(&*sql_app_module.resolve_ref()),
    )
    .await
    {
        Ok(_) => HttpResponse::Ok()
            .json(TotpActivationFinishedResponse { finished: true }),
        Err(err) => handle_mapped_error(err),
    }
}

/// Check TOTP token
///
/// This route should be used to check the TOTP token when tht totp app is
/// enabled.
///
#[utoipa::path(
    post,
    operation_id = "totp_check_token",
    request_body = TotpUpdatingValidationBody,
    responses(
        (
            status = 500,
            description = "Unknown internal server error.",
            body = HttpJsonResponse,
        ),
        (
            status = 403,
            description = "Forbidden.",
            body = HttpJsonResponse,
        ),
        (
            status = 401,
            description = "Unauthorized.",
            body = HttpJsonResponse,
        ),
        (
            status = 200,
            description = "Credentials are valid.",
            body = MyceliumLoginResponse,
        ),
    ),
)]
#[post("/totp/check-token")]
pub async fn totp_check_token_url(
    req: HttpRequest,
    body: web::Json<TotpUpdatingValidationBody>,
    auth_config: web::Data<InternalOauthConfig>,
    life_cycle_settings: web::Data<AccountLifeCycle>,
    app_module: web::Data<SqlAppModule>,
) -> impl Responder {
    let (email, _) =
        match check_credentials_with_multi_identity_provider(req).await {
            Err(err) => {
                warn!("err: {:?}", err);
                return HttpResponse::InternalServerError()
                    .json(HttpJsonResponse::new_message(err));
            }
            Ok(res) => res,
        };

    match totp_check_token(
        email,
        body.token.to_owned(),
        life_cycle_settings.get_ref().to_owned(),
        Box::new(&*app_module.resolve_ref()),
    )
    .await
    {
        Ok(res) => {
            match encode_jwt(
                res.to_owned(),
                auth_config.get_ref().to_owned(),
                life_cycle_settings.get_ref().to_owned(),
                false,
            )
            .await
            {
                Err(err) => return err,
                Ok((token, duration)) => {
                    return HttpResponse::Ok().json(MyceliumLoginResponse {
                        token,
                        duration,
                        totp_required: false,
                        user: res,
                    })
                }
            }
        }
        Err(err) => handle_mapped_error(err),
    }
}

/// Disable TOTP
///
/// This route should be used to disable the TOTP app.
///
#[utoipa::path(
    post,
    operation_id = "totp_disable",
    request_body = TotpUpdatingValidationBody,
    responses(
        (
            status = 500,
            description = "Unknown internal server error.",
            body = HttpJsonResponse,
        ),
        (
            status = 403,
            description = "Forbidden.",
            body = HttpJsonResponse,
        ),
        (
            status = 401,
            description = "Unauthorized.",
            body = HttpJsonResponse,
        ),
        (
            status = 200,
            description = "Credentials are valid.",
            body = MyceliumLoginResponse,
        ),
    ),
)]
#[post("/totp/disable")]
pub async fn totp_disable_url(
    req: HttpRequest,
    body: web::Json<TotpUpdatingValidationBody>,
    life_cycle_settings: web::Data<AccountLifeCycle>,
    sql_app_module: web::Data<SqlAppModule>,
) -> impl Responder {
    let (email, _) =
        match check_credentials_with_multi_identity_provider(req).await {
            Err(err) => {
                warn!("err: {:?}", err);
                return HttpResponse::InternalServerError()
                    .json(HttpJsonResponse::new_message(err));
            }
            Ok(res) => res,
        };

    match totp_disable(
        email,
        body.token.to_owned(),
        life_cycle_settings.get_ref().to_owned(),
        Box::new(&*sql_app_module.resolve_ref()),
        Box::new(&*sql_app_module.resolve_ref()),
        Box::new(&*sql_app_module.resolve_ref()),
        Box::new(&*sql_app_module.resolve_ref()),
    )
    .await
    {
        Ok(_) => HttpResponse::NoContent().finish(),
        Err(err) => handle_mapped_error(err),
    }
}

/// Request magic link
///
/// Sends a magic link email to the given address. Always returns
/// `{ "sent": true }` regardless of whether the email is registered
/// (prevents user enumeration).
///
#[utoipa::path(
    post,
    operation_id = "request_magic_link",
    request_body = MagicLinkRequestBody,
    responses(
        (
            status = 500,
            description = "Unknown internal server error.",
            body = HttpJsonResponse,
        ),
        (
            status = 200,
            description = "Magic link email dispatched.",
            body = MagicLinkRequestResponse,
        ),
    ),
    security(()),
)]
#[post("/magic-link/request")]
pub async fn request_magic_link_url(
    body: web::Json<MagicLinkRequestBody>,
    life_cycle_settings: web::Data<AccountLifeCycle>,
    sql_app_module: web::Data<SqlAppModule>,
) -> impl Responder {
    let email = match Email::from_string(body.email.to_owned()) {
        Err(err) => {
            warn!("Invalid email: {}", err);
            return HttpResponse::BadRequest().json(
                HttpJsonResponse::new_message(
                    "Invalid email address.".to_string(),
                ),
            );
        }
        Ok(email) => email,
    };

    // Resolve the base display URL here in the port layer — the use-case
    // must not know about HTTP routes or the API scope prefix.
    let display_base_url = match life_cycle_settings.domain_url.clone() {
        None => {
            warn!("domain_url not configured; suppressing magic link request");
            return HttpResponse::Ok()
                .json(MagicLinkRequestResponse { sent: true });
        }
        Some(resolver) => match resolver.async_get_or_error().await {
            Err(err) => {
                warn!("domain_url resolution failed (suppressed): {:?}", err);
                return HttpResponse::Ok()
                    .json(MagicLinkRequestResponse { sent: true });
            }
            Ok(url) => format!(
                "{}/{}/{}/magic-link/display",
                url.trim_end_matches('/'),
                ADMIN_API_SCOPE,
                build_actor_context(SystemActor::Beginner, UrlGroup::Users,),
            ),
        },
    };

    // Always respond with sent:true to prevent user enumeration.
    // Log errors but do not propagate them to the caller.
    if let Err(err) = request_magic_link(
        email,
        display_base_url,
        life_cycle_settings.get_ref().to_owned(),
        Box::new(&*sql_app_module.resolve_ref()),
        Box::new(&*sql_app_module.resolve_ref()),
        Box::new(&*sql_app_module.resolve_ref()),
    )
    .await
    {
        warn!("request_magic_link error (suppressed): {:?}", err);
    }

    HttpResponse::Ok().json(MagicLinkRequestResponse { sent: true })
}

/// Display magic link code
///
/// Single-use gateway-rendered HTML page that shows the 6-digit code.
/// Consumes the display token on first visit; subsequent visits render an
/// error page.
///
#[utoipa::path(
    get,
    operation_id = "display_magic_link",
    params(MagicLinkDisplayParams),
    responses(
        (
            status = 200,
            description = "HTML page with the 6-digit code.",
            content_type = "text/html",
        ),
        (
            status = 401,
            description = "Link invalid or already used.",
            content_type = "text/html",
        ),
    ),
    security(()),
)]
#[get("/magic-link/display")]
pub async fn display_magic_link_url(
    query: web::Query<MagicLinkDisplayParams>,
    sql_app_module: web::Data<SqlAppModule>,
) -> impl Responder {
    let email = match Email::from_string(query.email.to_owned()) {
        Err(err) => {
            warn!("Invalid email in magic link display: {}", err);
            return render_magic_link_error_page();
        }
        Ok(email) => email,
    };

    let token_repo: &dyn TokenInvalidation = &*sql_app_module.resolve_ref();
    let code = match token_repo
        .get_code_and_invalidate_display_token(&email, &query.token)
        .await
    {
        Ok(FetchResponseKind::Found(code)) => code,
        _ => return render_magic_link_error_page(),
    };

    let mut context = TeraContext::new();
    context.insert("code", &code);
    context.insert("app_name", "Mycelium");
    context.insert("expires_in_minutes", &15u32);

    match TEMPLATES.render("web/magic-link-display.html", &context) {
        Ok(html) => HttpResponse::Ok()
            .content_type("text/html; charset=utf-8")
            .body(html),
        Err(err) => {
            warn!("Failed to render magic-link-display template: {}", err);
            HttpResponse::InternalServerError().finish()
        }
    }
}

fn render_magic_link_error_page() -> HttpResponse {
    let mut context = TeraContext::new();
    context.insert("app_name", "Mycelium");

    match TEMPLATES.render("web/magic-link-display-error.html", &context) {
        Ok(html) => HttpResponse::Unauthorized()
            .content_type("text/html; charset=utf-8")
            .body(html),
        Err(err) => {
            warn!(
                "Failed to render magic-link-display-error template: {}",
                err
            );
            HttpResponse::Unauthorized().finish()
        }
    }
}

/// Verify magic link code and issue JWT
///
/// Validates `email` + `code`. On success returns a JWT in the same
/// `MyceliumLoginResponse` shape as the password login endpoint.
///
#[utoipa::path(
    post,
    operation_id = "verify_magic_link",
    request_body = MagicLinkVerifyBody,
    responses(
        (
            status = 500,
            description = "Unknown internal server error.",
            body = HttpJsonResponse,
        ),
        (
            status = 401,
            description = "Invalid or expired code.",
            body = HttpJsonResponse,
        ),
        (
            status = 200,
            description = "Code valid — JWT issued.",
            body = MyceliumLoginResponse,
        ),
    ),
    security(()),
)]
#[post("/magic-link/verify")]
pub async fn verify_magic_link_url(
    body: web::Json<MagicLinkVerifyBody>,
    app_module: web::Data<SqlAppModule>,
    auth_config: web::Data<InternalOauthConfig>,
    core_config: web::Data<AccountLifeCycle>,
) -> impl Responder {
    let email = match Email::from_string(body.email.to_owned()) {
        Err(err) => {
            warn!("Invalid email: {}", err);
            return HttpResponse::BadRequest().json(
                HttpJsonResponse::new_message(
                    "Invalid email address.".to_string(),
                ),
            );
        }
        Ok(email) => email,
    };

    let user = match verify_magic_link(
        email,
        body.code.to_owned(),
        Box::new(&*app_module.resolve_ref()),
        Box::new(&*app_module.resolve_ref()),
        Box::new(&*app_module.resolve_ref()),
        Box::new(&*app_module.resolve_ref()),
    )
    .await
    {
        Ok(user) => user,
        Err(err) => return handle_mapped_error(err),
    };

    match encode_jwt(
        user.to_owned(),
        auth_config.get_ref().to_owned(),
        core_config.get_ref().to_owned(),
        false,
    )
    .await
    {
        Err(err) => err,
        Ok((token, duration)) => {
            HttpResponse::Ok().json(MyceliumLoginResponse {
                token,
                duration,
                totp_required: false,
                user,
            })
        }
    }
}
