// Copyright 2024 RustFS Team
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
//     http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

use crate::admin::runtime_sources::{current_federated_identity_service, default_admin_usecase};
use crate::admin::storage_api::access::RequestContext;
use crate::license::has_valid_license;
use crate::server::has_path_prefix;
use crate::server::rate_limit::{
    LABEL_RATE_LIMIT_DIMENSION, LABEL_RATE_LIMIT_SCOPE, METRIC_HTTP_SERVER_REQUESTS_RATE_LIMITED_TOTAL,
    RATE_LIMIT_DIMENSION_CLIENT_IP, RATE_LIMIT_SCOPE_CONSOLE, RateLimitDecision, RateLimitQuota, RateLimiter,
    apply_throttle_headers, client_ip,
};
use crate::server::{
    APPLE_TOUCH_ICON_PATH, APPLE_TOUCH_ICON_PRECOMPOSED_PATH, CONSOLE_PREFIX, FAVICON_PATH, HEALTH_PREFIX, HEALTH_READY_PATH,
    HeaderMapCarrier, HealthProbe, LICENSE, RUSTFS_ADMIN_PREFIX, RequestContextLayer, VERSION, build_health_response_parts,
    collect_probe_readiness,
};
use crate::version::build;
use axum::{
    Extension, Json, Router,
    body::Body,
    extract::Request,
    middleware,
    response::{IntoResponse, Response},
    routing::get,
};
use http::{HeaderMap, HeaderName, HeaderValue, Method, StatusCode, Uri};
use mime_guess::from_path;
use opentelemetry::global;
use rust_embed::RustEmbed;
use serde::Serialize;
use std::{
    net::{IpAddr, SocketAddr},
    sync::{Arc, OnceLock},
    time::Duration,
};
use tower_http::catch_panic::CatchPanicLayer;
use tower_http::compression::CompressionLayer;
use tower_http::cors::{AllowOrigin, Any, CorsLayer};
use tower_http::limit::RequestBodyLimitLayer;
use tower_http::request_id::{MakeRequestUuid, PropagateRequestIdLayer, SetRequestIdLayer};
use tower_http::timeout::TimeoutLayer;
use tower_http::trace::TraceLayer;
use tracing::{Span, debug, error, info, instrument, warn};
use tracing_opentelemetry::OpenTelemetrySpanExt;

#[derive(RustEmbed)]
#[folder = "$CARGO_MANIFEST_DIR/static"]
struct StaticFiles;

/// Static file handler
///
/// Serves static files embedded in the binary using rust-embed.
/// If the requested file is not found, it serves index.html as a fallback.
/// If index.html is also not found, it returns a 404 Not Found response.
///
/// # Arguments:
/// - `uri`: The request URI.
///
/// # Returns:
/// - An `impl IntoResponse` containing the static file content or a 404 response.
///
async fn static_handler(uri: Uri) -> impl IntoResponse {
    let mut path = uri.path().trim_start_matches('/');
    if path.is_empty() {
        path = "index.html"
    }

    // Try the exact path first
    if let Some(file) = StaticFiles::get(path) {
        let mime_type = from_path(path).first_or_octet_stream();
        return Response::builder()
            .status(StatusCode::OK)
            .header("Content-Type", mime_type.to_string())
            .body(Body::from(file.data))
            .unwrap();
    }

    // For directory paths (trailing slash), try <path>index.html
    if path.ends_with('/') {
        let index_path = format!("{path}index.html");
        if let Some(file) = StaticFiles::get(&index_path) {
            let mime_type = from_path(&index_path).first_or_octet_stream();
            return Response::builder()
                .status(StatusCode::OK)
                .header("Content-Type", mime_type.to_string())
                .body(Body::from(file.data))
                .unwrap();
        }
    }

    // SPA fallback: serve root index.html for client-side routing
    if let Some(file) = StaticFiles::get("index.html") {
        let mime_type = from_path("index.html").first_or_octet_stream();
        Response::builder()
            .status(StatusCode::OK)
            .header("Content-Type", mime_type.to_string())
            .body(Body::from(file.data))
            .unwrap()
    } else {
        Response::builder()
            .status(StatusCode::NOT_FOUND)
            .body(Body::from(" 404 Not Found \n RustFS "))
            .unwrap()
    }
}

#[derive(Debug, Serialize, Clone)]
pub(crate) struct Config {
    #[serde(skip)]
    #[allow(
        dead_code,
        reason = "reachable only from this file's tests: no route registers config_handler (backlog#1823)"
    )]
    port: u16,
    api: Api,
    s3: S3,
    release: Release,
    license: License,
    doc: String,
    #[serde(skip_serializing_if = "Vec::is_empty")]
    oidc: Vec<OidcProviderInfo>,
}

#[derive(Debug, Serialize, Clone)]
struct OidcProviderInfo {
    provider_id: String,
    display_name: String,
}

impl Config {
    fn new(local_ip: IpAddr, port: u16, version: &str, date: &str) -> Self {
        let http_prefix = rustfs_config::RUSTFS_HTTP_PREFIX;

        // Collect OIDC provider info if available
        let oidc = current_federated_identity_service()
            .map(|federation| {
                federation
                    .list_visible_providers()
                    .into_iter()
                    .map(|p| OidcProviderInfo {
                        provider_id: p.provider_id,
                        display_name: p.display_name,
                    })
                    .collect()
            })
            .unwrap_or_default();

        Config {
            port,
            api: Api {
                base_url: build_console_api_base_url(&format!("{http_prefix}{local_ip}:{port}")),
                discovery: console_api_discovery(),
            },
            s3: S3 {
                endpoint: format!("{http_prefix}{local_ip}:{port}"),
                region: rustfs_config::RUSTFS_REGION.to_string(),
            },
            release: Release {
                version: version.to_string(),
                date: date.to_string(),
            },
            license: License {
                name: rustfs_config::RUSTFS_LICENSE.to_string(),
                url: rustfs_config::RUSTFS_LICENSE_URL.to_string(),
            },
            doc: rustfs_config::RUSTFS_DOCS_URL.to_string(),
            oidc,
        }
    }

    #[allow(
        dead_code,
        reason = "reachable only from this file's tests: no route registers config_handler (backlog#1823)"
    )]
    fn to_json(&self) -> String {
        serde_json::to_string(self).unwrap_or_default()
    }

    pub(crate) fn version_info(&self) -> String {
        format!(
            "RELEASE.{}@{} (rust {} {})",
            self.release.date.clone(),
            self.release.version.clone().trim_start_matches('@'),
            build::RUST_VERSION,
            build::BUILD_TARGET
        )
    }
}

fn build_console_api_base_url(base_url: &str) -> String {
    format!("{base_url}{RUSTFS_ADMIN_PREFIX}")
}

#[derive(Debug, Serialize, Clone)]
struct Api {
    #[serde(rename = "baseURL")]
    base_url: String,
    discovery: ApiDiscovery,
}

#[derive(Debug, Serialize, Clone)]
struct ApiDiscovery {
    #[serde(rename = "runtimeCapabilities")]
    runtime_capabilities: String,
    #[serde(rename = "clusterSnapshot")]
    cluster_snapshot: String,
    #[serde(rename = "extensionsCatalog")]
    extensions_catalog: String,
    #[serde(rename = "ilmExpiryStatus")]
    ilm_expiry_status: String,
    #[serde(rename = "ilmTransitionRun")]
    ilm_transition_run: String,
    #[serde(rename = "ilmTransitionJob")]
    ilm_transition_job: String,
}

fn console_api_discovery() -> ApiDiscovery {
    let usecase = default_admin_usecase();
    ApiDiscovery {
        runtime_capabilities: usecase.runtime_capabilities_route().to_string(),
        cluster_snapshot: usecase.cluster_snapshot_route().to_string(),
        extensions_catalog: usecase.extensions_catalog_route().to_string(),
        ilm_expiry_status: format!("{RUSTFS_ADMIN_PREFIX}/ilm/expiry/status"),
        ilm_transition_run: format!("{RUSTFS_ADMIN_PREFIX}/ilm/transition/run"),
        ilm_transition_job: format!("{RUSTFS_ADMIN_PREFIX}/ilm/transition/jobs/{{job_id}}"),
    }
}

#[derive(Debug, Serialize, Clone)]
struct S3 {
    endpoint: String,
    region: String,
}

#[derive(Debug, Serialize, Clone)]
struct Release {
    version: String,
    date: String,
}

#[derive(Debug, Serialize, Clone)]
struct License {
    name: String,
    url: String,
}

/// Global console configuration
static CONSOLE_CONFIG: OnceLock<Config> = OnceLock::new();

#[allow(clippy::const_is_empty)]
pub(crate) fn init_console_cfg(local_ip: IpAddr, port: u16) {
    CONSOLE_CONFIG.get_or_init(|| {
        let ver = {
            if !build::TAG.is_empty() {
                build::TAG.to_string()
            } else if !build::SHORT_COMMIT.is_empty() {
                format!("@{}", build::SHORT_COMMIT)
            } else {
                build::PKG_VERSION.to_string()
            }
        };

        Config::new(local_ip, port, ver.as_str(), build::COMMIT_DATE_3339)
    });
}

#[derive(Serialize)]
struct LicensePublicStatus {
    licensed: bool,
}

/// Returns coarse public license status without exposing license metadata.
#[instrument]
async fn license_handler() -> impl IntoResponse {
    Json(LicensePublicStatus {
        licensed: has_valid_license(),
    })
}

/// Check if the given IP address is a private IP
fn _is_private_ip(ip: IpAddr) -> bool {
    match ip {
        IpAddr::V4(ip) => {
            let octets = ip.octets();
            // 10.0.0.0/8
            octets[0] == 10 ||
                // 172.16.0.0/12
                (octets[0] == 172 && (octets[1] >= 16 && octets[1] <= 31)) ||
                // 192.168.0.0/16
                (octets[0] == 192 && octets[1] == 168)
        }
        IpAddr::V6(_) => false,
    }
}

/// Version handler
/// Returns the current version information of the console.
///
/// # Returns:
/// - 200 OK with JSON body containing version details if configuration is initialized.
/// - 500 Internal Server Error if configuration is not initialized.
#[instrument]
async fn version_handler() -> impl IntoResponse {
    match CONSOLE_CONFIG.get() {
        Some(cfg) => Response::builder()
            .header("content-type", "application/json")
            .status(StatusCode::OK)
            .body(Body::from(
                serde_json::json!({
                    "version": cfg.release.version,
                    "version_info": cfg.version_info(),
                    "date": cfg.release.date,
                })
                .to_string(),
            ))
            .unwrap(),
        None => Response::builder()
            .status(StatusCode::INTERNAL_SERVER_ERROR)
            .body(Body::from("Console configuration not initialized"))
            .unwrap(),
    }
}

/// Configuration handler
/// Returns the current console configuration in JSON format.
/// The configuration is dynamically adjusted based on the request's host and scheme.
///
/// # Arguments:
/// - `uri`: The request URI.
/// - `headers`: The request headers.
///
/// # Returns:
/// - 200 OK with JSON body containing the console configuration if initialized.
/// - 500 Internal Server Error if configuration is not initialized.
#[instrument(fields(uri))]
#[allow(
    dead_code,
    reason = "reachable only from this file's tests: no route registers it (backlog#1823)"
)]
async fn config_handler(uri: Uri, headers: HeaderMap) -> impl IntoResponse {
    // Get the scheme from the headers or use the URI scheme
    let scheme = headers
        .get(HeaderName::from_static("x-forwarded-proto"))
        .and_then(|value| value.to_str().ok())
        .unwrap_or_else(|| uri.scheme().map(|s| s.as_str()).unwrap_or("http"));

    // Prefer URI host, fallback to `Host` header
    let header_host = headers
        .get(http::header::HOST)
        .and_then(|v| v.to_str().ok())
        .unwrap_or_default();

    let raw_host = uri.host().unwrap_or(header_host);

    let host_for_url = if let Ok(socket_addr) = raw_host.parse::<SocketAddr>() {
        // Successfully parsed, it's in IP:Port format.
        // For IPv6, we need to enclose it in brackets to form a valid URL.
        let ip = socket_addr.ip();
        if ip.is_ipv6() { format!("[{ip}]") } else { format!("{ip}") }
    } else if let Ok(ip) = raw_host.parse::<IpAddr>() {
        // Pure IP (no ports)
        if ip.is_ipv6() { format!("[{ip}]") } else { ip.to_string() }
    } else {
        // The domain name may not be able to resolve directly to IP, remove the port
        raw_host.split(':').next().unwrap_or(raw_host).to_string()
    };

    // Make a copy of the current configuration
    let mut cfg = match CONSOLE_CONFIG.get() {
        Some(cfg) => cfg.clone(),
        None => {
            error!("Console configuration not initialized");
            return Response::builder()
                .status(StatusCode::INTERNAL_SERVER_ERROR)
                .body(Body::from("Console configuration not initialized"))
                .unwrap();
        }
    };

    let url = format!("{}://{}:{}", scheme, host_for_url, cfg.port);
    cfg.api.base_url = build_console_api_base_url(&url);
    cfg.s3.endpoint = url;

    Response::builder()
        .header("content-type", "application/json")
        .status(StatusCode::OK)
        .body(Body::from(cfg.to_json()))
        .unwrap()
}

/// Console access logging middleware
/// Logs each console access with method, URI, status code, and duration.
///
/// # Arguments:
/// - `req`: The incoming request.
/// - `next`: The next middleware or handler in the chain.
///
/// # Returns:
/// - The response from the next middleware or handler.
async fn console_logging_middleware(req: Request, next: middleware::Next) -> Response {
    let method = req.method().clone();
    let uri = req.uri().clone();
    let start = std::time::Instant::now();
    let response = next.run(req).await;
    let duration = start.elapsed();
    let request_id = response
        .headers()
        .get("x-request-id")
        .and_then(|v| v.to_str().ok())
        .unwrap_or("unknown")
        .to_string();

    info!(
        target: "rustfs::console::access",
        request_id = %request_id,
        method = %method,
        uri = %uri,
        status = %response.status(),
        duration_ms = %duration.as_millis(),
        "Console access"
    );

    response
}

/// Get console configuration from environment variables
/// Returns a tuple containing console configuration values from environment variables.
///
/// # Returns:
/// - rate_limit_enable: bool indicating if rate limiting is enabled.
/// - rate_limit_rpm: u32 indicating the rate limit in requests per minute.
/// - auth_timeout: u64 indicating the authentication timeout in seconds.
/// - cors_allowed_origins: String containing allowed CORS origins.
///
fn get_console_config_from_env() -> (bool, u32, u64, String) {
    let rate_limit_enable = std::env::var(rustfs_config::ENV_CONSOLE_RATE_LIMIT_ENABLE)
        .unwrap_or_else(|_| rustfs_config::DEFAULT_CONSOLE_RATE_LIMIT_ENABLE.to_string())
        .parse::<bool>()
        .unwrap_or(rustfs_config::DEFAULT_CONSOLE_RATE_LIMIT_ENABLE);

    let rate_limit_rpm = std::env::var(rustfs_config::ENV_CONSOLE_RATE_LIMIT_RPM)
        .unwrap_or_else(|_| rustfs_config::DEFAULT_CONSOLE_RATE_LIMIT_RPM.to_string())
        .parse::<u32>()
        .unwrap_or(rustfs_config::DEFAULT_CONSOLE_RATE_LIMIT_RPM);

    let auth_timeout = std::env::var(rustfs_config::ENV_CONSOLE_AUTH_TIMEOUT)
        .unwrap_or_else(|_| rustfs_config::DEFAULT_CONSOLE_AUTH_TIMEOUT.to_string())
        .parse::<u64>()
        .unwrap_or(rustfs_config::DEFAULT_CONSOLE_AUTH_TIMEOUT);
    let cors_allowed_origins = std::env::var(rustfs_config::ENV_CONSOLE_CORS_ALLOWED_ORIGINS)
        .unwrap_or_else(|_| rustfs_config::DEFAULT_CONSOLE_CORS_ALLOWED_ORIGINS.to_string())
        .parse::<String>()
        .unwrap_or_else(|_| rustfs_config::DEFAULT_CONSOLE_CORS_ALLOWED_ORIGINS.to_string());

    (rate_limit_enable, rate_limit_rpm, auth_timeout, cors_allowed_origins)
}

/// Check if the given path is for console access
///
/// # Arguments:
/// - `path`: The request path.
///
/// # Returns:
/// - `true` if the path is for console access, `false` otherwise.
pub fn is_console_path(path: &str) -> bool {
    matches!(path, FAVICON_PATH | APPLE_TOUCH_ICON_PATH | APPLE_TOUCH_ICON_PRECOMPOSED_PATH)
        || has_path_prefix(path, CONSOLE_PREFIX)
}

/// Setup comprehensive middleware stack with tower-http features
///
/// # Arguments:
/// - `cors_layer`: The CORS layer to apply.
/// - `rate_limit_enable`: bool indicating if rate limiting is enabled.
/// - `rate_limit_rpm`: u32 indicating the rate limit in requests per minute.
/// - `auth_timeout`: u64 indicating the authentication timeout in seconds.
///
/// # Returns:
/// - A `Router` with the configured middleware stack.
fn setup_console_middleware_stack(
    cors_layer: CorsLayer,
    rate_limit_enable: bool,
    rate_limit_rpm: u32,
    auth_timeout: u64,
) -> Router {
    let mut app = Router::new()
        .route(FAVICON_PATH, get(static_handler))
        .route(&format!("{CONSOLE_PREFIX}{LICENSE}"), get(license_handler))
        .route(&format!("{CONSOLE_PREFIX}{VERSION}"), get(version_handler))
        .nest(CONSOLE_PREFIX, Router::new().fallback_service(get(static_handler)))
        .fallback_service(get(static_handler));

    if rustfs_utils::get_env_bool(rustfs_config::ENV_HEALTH_ENDPOINT_ENABLE, rustfs_config::DEFAULT_HEALTH_ENDPOINT_ENABLE) {
        app = app
            .route(&format!("{CONSOLE_PREFIX}{HEALTH_PREFIX}"), get(health_check).head(health_check))
            .route(
                &format!("{CONSOLE_PREFIX}{}", crate::server::HEALTH_COMPAT_LIVE_PATH),
                get(health_check).head(health_check),
            )
            .route(&format!("{CONSOLE_PREFIX}{HEALTH_READY_PATH}"), get(health_check).head(health_check));
    } else {
        // Keep disabled health probes from falling through to the SPA fallback.
        app = app
            .route(
                &format!("{CONSOLE_PREFIX}{HEALTH_PREFIX}"),
                get(health_route_disabled).head(health_route_disabled),
            )
            .route(
                &format!("{CONSOLE_PREFIX}{}", crate::server::HEALTH_COMPAT_LIVE_PATH),
                get(health_route_disabled).head(health_route_disabled),
            )
            .route(
                &format!("{CONSOLE_PREFIX}{HEALTH_READY_PATH}"),
                get(health_route_disabled).head(health_route_disabled),
            );
    }

    // Add comprehensive middleware layers using tower-http features
    app = app
        .layer(CatchPanicLayer::new())
        .layer(
            TraceLayer::new_for_http()
                .make_span_with(|request: &Request| {
                    let request_context = request.extensions().get::<RequestContext>();
                    let request_id = request_context.map(|ctx| ctx.request_id.as_str()).unwrap_or("unknown");
                    let trace_id = request_context.and_then(|ctx| ctx.trace_id.as_deref()).unwrap_or("unknown");
                    let span_id = request_context.and_then(|ctx| ctx.span_id.as_deref()).unwrap_or("unknown");

                    let parent_context = global::get_text_map_propagator(|propagator| {
                        propagator.extract(&HeaderMapCarrier::new(request.headers()))
                    });

                    let span = tracing::info_span!(
                        "console-request",
                        request_id = %request_id,
                        trace_id = %trace_id,
                        span_id = %span_id,
                        method = %request.method(),
                        uri = %request.uri(),
                        status_code = tracing::field::Empty,
                    );

                    if span.is_disabled() {
                        return span;
                    }

                    if let Err(err) = span.set_parent(parent_context) {
                        debug!(error = ?err, "Failed to propagate tracing context for console request");
                    }

                    span
                })
                .on_response(|response: &Response, _latency: Duration, span: &Span| {
                    span.record("status_code", tracing::field::display(response.status()));
                }),
        )
        .layer(PropagateRequestIdLayer::x_request_id())
        .layer(RequestContextLayer)
        .layer(SetRequestIdLayer::x_request_id(MakeRequestUuid))
        // Compress responses
        .layer(CompressionLayer::new())
        .layer(middleware::from_fn(console_logging_middleware))
        .layer(cors_layer)
        // Add timeout layer - convert auth_timeout from seconds to Duration
        .layer(TimeoutLayer::with_status_code(
            StatusCode::REQUEST_TIMEOUT,
            Duration::from_secs(auth_timeout),
        ))
        // Add request body limit (10MB for console uploads)
        .layer(RequestBodyLimitLayer::new(5 * 1024 * 1024 * 1024));

    // Per-client console rate limiting, sharing the S3 API limiter core
    // (`server::rate_limit`). Added last so it is the outermost layer and
    // over-limit requests are rejected before any other middleware runs.
    // The client identity comes from validated request extensions only
    // (trusted-proxy `ClientInfo`, then socket peer address); requests
    // without one fail open rather than trusting spoofable headers.
    if rate_limit_enable {
        if let Some(quota) = RateLimitQuota::per_minute(rate_limit_rpm, 0) {
            let limiter: Arc<RateLimiter> = Arc::new(RateLimiter::new(quota));
            app = app.layer(middleware::from_fn(move |req: Request, next: middleware::Next| {
                let limiter = limiter.clone();
                async move {
                    match client_ip(&req).map(|ip| limiter.check(&ip)) {
                        Some(RateLimitDecision::Limited(throttle)) => {
                            metrics::counter!(
                                METRIC_HTTP_SERVER_REQUESTS_RATE_LIMITED_TOTAL,
                                LABEL_RATE_LIMIT_SCOPE => RATE_LIMIT_SCOPE_CONSOLE,
                                LABEL_RATE_LIMIT_DIMENSION => RATE_LIMIT_DIMENSION_CLIENT_IP
                            )
                            .increment(1);
                            debug!(retry_after_secs = throttle.retry_after_secs, "Console request rejected by rate limit");
                            let mut response = StatusCode::TOO_MANY_REQUESTS.into_response();
                            apply_throttle_headers(response.headers_mut(), limiter.quota().requests_per_minute, &throttle);
                            response
                        }
                        _ => next.run(req).await,
                    }
                }
            }));
            info!("Console rate limiting enabled: {} requests per minute", rate_limit_rpm);
        } else {
            warn!("Console rate limiting enabled but RPM is 0; it stays inactive");
        }
    }

    app
}

/// Console health check handler with comprehensive health information
///
/// # Arguments:
/// - `method`: The HTTP method of the request.
///
/// # Returns:
/// - A `Response` containing the health check result.
#[instrument]
async fn health_check(
    method: Method,
    uri: Uri,
    server_ctx: Option<Extension<Arc<crate::runtime_sources::ServerContextSlot>>>,
) -> Response {
    let probe = if uri.path().strip_prefix(CONSOLE_PREFIX) == Some(HEALTH_READY_PATH) {
        HealthProbe::Readiness
    } else {
        HealthProbe::Liveness
    };
    let app_context = match server_ctx {
        Some(Extension(server_ctx)) => server_ctx.installed_app_context(),
        None => crate::runtime_sources::current_app_context(),
    };
    let object_traffic_health = app_context.map(|context| context.object_traffic_health());
    let readiness_report = collect_probe_readiness(probe, object_traffic_health.as_deref()).await;
    let uptime = std::time::SystemTime::now()
        .duration_since(std::time::UNIX_EPOCH)
        .unwrap_or_default()
        .as_secs();
    let response_parts =
        build_health_response_parts(method.clone(), probe, readiness_report.as_ref(), "rustfs-console", Some(uptime), None);

    let builder = Response::builder()
        .status(response_parts.status_code)
        .header("content-type", "application/json");

    match method {
        // GET: Returns complete JSON
        Method::GET => {
            let body_json = response_parts.payload.unwrap_or_else(|| {
                serde_json::json!({
                    "status": "error",
                    "service": "rustfs-console",
                })
            });

            // Return a minimal JSON when serialization fails to avoid panic
            let body_str = serde_json::to_string(&body_json).unwrap_or_else(|e| {
                error!(
                    target: "rustfs::console::health",
                    "failed to serialize health check body: {}",
                    e
                );
                // Simplified back-up JSON
                "{\"status\":\"error\",\"service\":\"rustfs-console\"}".to_string()
            });
            builder.body(Body::from(body_str)).unwrap_or_else(|e| {
                error!(
                    target: "rustfs::console::health",
                    "failed to build GET health response: {}",
                    e
                );
                Response::builder()
                    .status(StatusCode::INTERNAL_SERVER_ERROR)
                    .body(Body::from("failed to build response"))
                    .unwrap_or_else(|_| Response::new(Body::from("")))
            })
        }

        // HEAD: Only status + headers are returned, body is empty
        Method::HEAD => builder.body(Body::empty()).unwrap_or_else(|e| {
            error!(
                target: "rustfs::console::health",
                "failed to build HEAD health response: {}",
                e
            );
            Response::builder()
                .status(StatusCode::INTERNAL_SERVER_ERROR)
                .body(Body::from("failed to build response"))
                .unwrap_or_else(|e| {
                    error!(
                        target: "rustfs::console::health",
                        "failed to build HEAD health empty response, reason: {}",
                        e
                    );
                    Response::new(Body::from(""))
                })
        }),

        // Other methods: 405
        _ => Response::builder()
            .status(StatusCode::METHOD_NOT_ALLOWED)
            .header("allow", "GET, HEAD")
            .body(Body::from("Method Not Allowed"))
            .unwrap_or_else(|e| {
                error!(
                    target: "rustfs::console::health",
                    "failed to build 405 response: {}",
                    e
                );
                Response::new(Body::from("Method Not Allowed"))
            }),
    }
}

async fn health_route_disabled() -> StatusCode {
    StatusCode::NOT_FOUND
}

/// Parse CORS allowed origins from configuration.
///
/// When no origins are configured (None or an empty string), the layer is
/// left without `Access-Control-Allow-Origin` so browsers treat responses
/// as same-origin only. Operators that need cross-origin access set
/// `RUSTFS_CONSOLE_CORS_ALLOWED_ORIGINS` to a comma-separated allow-list,
/// or to `*` to allow any origin.
///
/// # Arguments
/// - `origins`: An optional reference to a string containing allowed origins.
///
/// # Returns
/// - A `CorsLayer` configured with the specified origins.
pub fn parse_cors_origins(origins: Option<&String>) -> CorsLayer {
    let cors_layer = CorsLayer::new()
        .allow_methods([Method::GET, Method::POST, Method::PUT, Method::DELETE, Method::OPTIONS])
        .allow_headers(Any);

    match origins {
        Some(origins_str) if origins_str.trim() == "*" => cors_layer.allow_origin(Any).expose_headers(Any),
        Some(origins_str) if !origins_str.trim().is_empty() => {
            let origins: Vec<&str> = origins_str.split(',').map(|s| s.trim()).filter(|s| !s.is_empty()).collect();
            let mut valid_origins = Vec::new();
            for origin in origins {
                match origin.parse::<HeaderValue>() {
                    Ok(header_value) => valid_origins.push(header_value),
                    Err(e) => warn!("Invalid CORS origin '{}': {}", origin, e),
                }
            }

            if valid_origins.is_empty() {
                warn!("No valid CORS origins parsed from configuration; defaulting to same-origin only");
                cors_layer
            } else {
                info!("Console CORS origins configured: {:?}", valid_origins);
                cors_layer.allow_origin(AllowOrigin::list(valid_origins)).expose_headers(Any)
            }
        }
        _ => {
            debug!("No CORS origins configured for console; same-origin only");
            cors_layer
        }
    }
}

/// Create and configure the console server router
///
/// # Returns:
/// - A `Router` configured for the console server with middleware.
pub(crate) fn make_console_server() -> Router {
    let (rate_limit_enable, rate_limit_rpm, auth_timeout, cors_allowed_origins) = get_console_config_from_env();
    // String to Option<&String>
    let cors_allowed_origins = if cors_allowed_origins.is_empty() {
        None
    } else {
        Some(&cors_allowed_origins)
    };
    // Configure CORS based on settings
    let cors_layer = parse_cors_origins(cors_allowed_origins);

    // Build console router with enhanced middleware stack using tower-http features
    setup_console_middleware_stack(cors_layer, rate_limit_enable, rate_limit_rpm, auth_timeout)
}

#[cfg(test)]
mod tests {
    use super::*;
    use axum::body::Body;
    use axum::routing::get;
    use http::{Request, StatusCode};
    use http_body_util::BodyExt;
    use serial_test::serial;
    use std::io;
    use std::net::{IpAddr, Ipv4Addr};
    use std::sync::{Arc, Mutex};
    use temp_env::async_with_vars;
    use tower::ServiceExt;
    use tracing_subscriber::{Registry, layer::SubscriberExt};

    #[derive(Clone, Default)]
    struct SharedWriter {
        inner: Arc<Mutex<Vec<u8>>>,
    }

    struct SharedWriterGuard {
        inner: Arc<Mutex<Vec<u8>>>,
    }

    impl<'writer> tracing_subscriber::fmt::MakeWriter<'writer> for SharedWriter {
        type Writer = SharedWriterGuard;

        fn make_writer(&'writer self) -> Self::Writer {
            SharedWriterGuard {
                inner: Arc::clone(&self.inner),
            }
        }
    }

    impl io::Write for SharedWriterGuard {
        fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
            let mut inner = self.inner.lock().expect("shared writer lock should not be poisoned");
            inner.extend_from_slice(buf);
            Ok(buf.len())
        }

        fn flush(&mut self) -> io::Result<()> {
            Ok(())
        }
    }

    #[test]
    fn console_api_base_url_keeps_rustfs_admin_prefix() {
        let cfg = Config::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 9001, "test", "2026-03-16T00:00:00Z");

        assert!(
            cfg.api.base_url.ends_with("/rustfs/admin/v3"),
            "console baseURL must keep using the RustFS admin prefix"
        );
        assert!(
            !cfg.api.base_url.ends_with("/minio/admin/v3"),
            "console baseURL must not switch to the MinIO admin prefix by default"
        );
    }

    #[test]
    fn console_api_base_url_builder_preserves_existing_console_contract() {
        assert_eq!(
            build_console_api_base_url("http://127.0.0.1:9001"),
            "http://127.0.0.1:9001/rustfs/admin/v3"
        );
    }

    #[test]
    fn console_config_exposes_admin_discovery_paths() {
        let cfg = Config::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 9001, "test", "2026-03-16T00:00:00Z");

        assert_eq!(cfg.api.discovery.runtime_capabilities, "/rustfs/admin/v4/runtime/capabilities");
        assert_eq!(cfg.api.discovery.cluster_snapshot, "/rustfs/admin/v4/cluster/snapshot");
        assert_eq!(cfg.api.discovery.extensions_catalog, "/rustfs/admin/v4/extensions/catalog");
        assert_eq!(cfg.api.discovery.ilm_expiry_status, "/rustfs/admin/v3/ilm/expiry/status");
        assert_eq!(cfg.api.discovery.ilm_transition_run, "/rustfs/admin/v3/ilm/transition/run");
        assert_eq!(cfg.api.discovery.ilm_transition_job, "/rustfs/admin/v3/ilm/transition/jobs/{job_id}");
    }

    #[tokio::test]
    async fn console_config_handler_serializes_admin_discovery_paths() {
        init_console_cfg(IpAddr::V4(Ipv4Addr::LOCALHOST), 9001);

        let response = config_handler(Uri::from_static("http://127.0.0.1:9001/rustfs/console/api/v1/config"), HeaderMap::new())
            .await
            .into_response();

        assert_eq!(response.status(), StatusCode::OK);
        let body = response.into_body();
        let bytes = body.collect().await.expect("collect console config body").to_bytes();
        let value: serde_json::Value = serde_json::from_slice(&bytes).expect("console config JSON should deserialize");

        assert_eq!(value["api"]["discovery"]["runtimeCapabilities"], "/rustfs/admin/v4/runtime/capabilities");
        assert_eq!(value["api"]["discovery"]["clusterSnapshot"], "/rustfs/admin/v4/cluster/snapshot");
        assert_eq!(value["api"]["discovery"]["extensionsCatalog"], "/rustfs/admin/v4/extensions/catalog");
        assert_eq!(value["api"]["discovery"]["ilmExpiryStatus"], "/rustfs/admin/v3/ilm/expiry/status");
        assert_eq!(value["api"]["discovery"]["ilmTransitionRun"], "/rustfs/admin/v3/ilm/transition/run");
        assert_eq!(
            value["api"]["discovery"]["ilmTransitionJob"],
            "/rustfs/admin/v3/ilm/transition/jobs/{job_id}"
        );
    }

    #[test]
    fn external_admin_paths_are_not_console_paths() {
        assert!(is_console_path("/rustfs/console/"));
        assert!(is_console_path("/apple-touch-icon.png"));
        assert!(is_console_path("/apple-touch-icon-precomposed.png"));
        assert!(!is_console_path("/minio/admin/v3/info"));
        assert!(!is_console_path("/rustfs/admin/v3/info"));
    }

    // setup_console_middleware_stack reads ENV_HEALTH_ENDPOINT_ENABLE; serialise
    // with other tests that override that env var to avoid cross-task leakage.
    #[tokio::test]
    #[serial]
    async fn console_middleware_stack_propagates_request_id_header() {
        let app = setup_console_middleware_stack(parse_cors_origins(None), false, 0, 30);
        let request = Request::builder()
            .uri(format!("{CONSOLE_PREFIX}{HEALTH_PREFIX}"))
            .body(Body::empty())
            .expect("failed to build request");

        let response = app.oneshot(request).await.expect("request should succeed");
        assert_eq!(response.status(), StatusCode::OK);
        assert!(
            response.headers().contains_key("x-request-id"),
            "console response should include propagated x-request-id header"
        );
    }

    #[tokio::test]
    #[serial]
    async fn console_readiness_uses_the_request_server_object_progress() {
        temp_env::async_with_vars([(rustfs_config::ENV_HEALTH_MINIMAL_RESPONSE_ENABLE, Some("false"))], async {
            let object_traffic_health =
                Arc::new(crate::app::object_traffic_health::ObjectTrafficHealth::enabled_for_test(Duration::ZERO));
            let stalled = object_traffic_health
                .track_write_storage()
                .expect("write tracking must be enabled");
            let app_context =
                crate::app::gating_test_env::app_context_with_object_traffic_health(Arc::clone(&object_traffic_health)).await;
            let server_ctx = crate::runtime_sources::ServerContextSlot::new();
            assert!(server_ctx.install(app_context));

            let response = health_check(
                Method::GET,
                format!("{CONSOLE_PREFIX}{HEALTH_READY_PATH}")
                    .parse()
                    .expect("console readiness URI"),
                Some(Extension(server_ctx)),
            )
            .await;

            assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE);
            let body = response
                .into_body()
                .collect()
                .await
                .expect("console readiness body")
                .to_bytes();
            let payload: serde_json::Value = serde_json::from_slice(&body).expect("console readiness JSON");
            assert_eq!(payload["ready"], false);
            assert_eq!(payload["degradedReasons"], serde_json::json!(["object_write_stalled"]));
            drop(stalled);
        })
        .await;
    }

    #[tokio::test]
    #[serial]
    async fn console_liveness_omits_readiness_state() {
        temp_env::async_with_vars([(rustfs_config::ENV_HEALTH_MINIMAL_RESPONSE_ENABLE, Some("false"))], async {
            let object_traffic_health =
                Arc::new(crate::app::object_traffic_health::ObjectTrafficHealth::enabled_for_test(Duration::ZERO));
            let _stalled = object_traffic_health
                .track_write_storage()
                .expect("write tracking must be enabled");
            let app_context =
                crate::app::gating_test_env::app_context_with_object_traffic_health(Arc::clone(&object_traffic_health)).await;
            let server_ctx = crate::runtime_sources::ServerContextSlot::new();
            assert!(server_ctx.install(app_context));

            let response = health_check(
                Method::GET,
                format!("{CONSOLE_PREFIX}{HEALTH_PREFIX}")
                    .parse()
                    .expect("console liveness URI"),
                Some(Extension(server_ctx)),
            )
            .await;

            assert_eq!(response.status(), StatusCode::OK);
            let body = response
                .into_body()
                .collect()
                .await
                .expect("console liveness body")
                .to_bytes();
            let payload: serde_json::Value = serde_json::from_slice(&body).expect("console liveness JSON");
            assert_eq!(payload["status"], "ok");
            assert!(payload.get("ready").is_none());
            assert!(payload.get("degradedReasons").is_none());
        })
        .await;
    }

    // setup_console_middleware_stack reads ENV_HEALTH_ENDPOINT_ENABLE (see above).
    #[tokio::test]
    #[serial]
    async fn console_rate_limit_rejects_over_limit_clients_with_429() {
        let app = setup_console_middleware_stack(parse_cors_origins(None), true, 1, 30);

        let request_for = |last_octet: u8| {
            let mut request = Request::builder()
                .uri(format!("{CONSOLE_PREFIX}/index.html"))
                .body(Body::empty())
                .expect("failed to build request");
            request.extensions_mut().insert(crate::server::RemoteAddr(SocketAddr::new(
                IpAddr::from([198, 51, 100, last_octet]),
                50000,
            )));
            request
        };

        let first = app.clone().oneshot(request_for(7)).await.expect("request should succeed");
        assert_ne!(first.status(), StatusCode::TOO_MANY_REQUESTS);

        let second = app.clone().oneshot(request_for(7)).await.expect("request should succeed");
        assert_eq!(second.status(), StatusCode::TOO_MANY_REQUESTS);
        assert!(
            second.headers().contains_key(http::header::RETRY_AFTER),
            "console 429 must carry Retry-After"
        );

        // A different client IP has its own budget.
        let other = app.oneshot(request_for(8)).await.expect("request should succeed");
        assert_ne!(other.status(), StatusCode::TOO_MANY_REQUESTS);
    }

    #[tokio::test(flavor = "current_thread")]
    async fn console_trace_layer_records_request_id_on_current_span() {
        let writer = SharedWriter::default();
        let subscriber = Registry::default().with(
            tracing_subscriber::fmt::layer()
                .without_time()
                .with_target(false)
                .with_level(false)
                .with_ansi(false)
                .json()
                .flatten_event(true)
                .with_current_span(true)
                .with_span_list(true)
                .with_writer(writer.clone()),
        );
        let _guard = tracing::subscriber::set_default(subscriber);

        let app = Router::new()
            .route(
                "/trace-test",
                get(|| async {
                    tracing::info!("console handler log");
                    StatusCode::OK
                }),
            )
            .layer(
                TraceLayer::new_for_http()
                    .make_span_with(|request: &http::Request<_>| {
                        let request_context = request.extensions().get::<RequestContext>();
                        let request_id = request_context.map(|ctx| ctx.request_id.as_str()).unwrap_or("unknown");
                        let trace_id = request_context.and_then(|ctx| ctx.trace_id.as_deref()).unwrap_or("unknown");
                        let span_id = request_context.and_then(|ctx| ctx.span_id.as_deref()).unwrap_or("unknown");

                        let parent_context = global::get_text_map_propagator(|propagator| {
                            propagator.extract(&HeaderMapCarrier::new(request.headers()))
                        });

                        let span = tracing::info_span!(
                            "console-request",
                            request_id = %request_id,
                            trace_id = %trace_id,
                            span_id = %span_id,
                            method = %request.method(),
                            uri = %request.uri(),
                            status_code = tracing::field::Empty,
                        );

                        if span.is_disabled() {
                            return span;
                        }

                        if let Err(err) = span.set_parent(parent_context) {
                            debug!(error = ?err, "Failed to propagate tracing context for console request");
                        }

                        span
                    })
                    .on_response(|response: &Response, _latency: Duration, span: &Span| {
                        span.record("status_code", tracing::field::display(response.status()));
                    }),
            )
            .layer(RequestContextLayer)
            .layer(SetRequestIdLayer::x_request_id(MakeRequestUuid));

        app.oneshot(
            Request::builder()
                .uri("/trace-test")
                .body(Body::empty())
                .expect("failed to build trace test request"),
        )
        .await
        .expect("trace test request should complete");

        let output = String::from_utf8(
            writer
                .inner
                .lock()
                .expect("shared writer lock should not be poisoned")
                .clone(),
        )
        .expect("console trace log output should be valid UTF-8");

        let log = output
            .lines()
            .map(|line| serde_json::from_str::<serde_json::Value>(line).expect("console log line should be valid JSON"))
            .find(|value| value.get("message").and_then(serde_json::Value::as_str) == Some("console handler log"))
            .expect("expected console handler log entry");

        assert_eq!(log["span"]["method"], serde_json::Value::String("GET".to_string()));
        assert_eq!(log["span"]["name"], serde_json::Value::String("console-request".to_string()));
        assert!(
            log.get("span")
                .and_then(|span| span.get("request_id"))
                .and_then(serde_json::Value::as_str)
                .is_some(),
            "{output}"
        );
        assert_ne!(
            log.get("span")
                .and_then(|span| span.get("request_id"))
                .and_then(serde_json::Value::as_str),
            Some("unknown"),
            "{output}"
        );
    }

    /// Regression: when no console CORS origins are configured (the new
    /// default), the layer must NOT emit `Access-Control-Allow-Origin`, so
    /// browsers treat responses as same-origin only.
    #[tokio::test]
    #[serial]
    async fn default_console_cors_is_same_origin_only() {
        let app = setup_console_middleware_stack(parse_cors_origins(None), false, 0, 30);

        let request = Request::builder()
            .method("OPTIONS")
            .uri(format!("{CONSOLE_PREFIX}/license"))
            .header("origin", "https://example.com")
            .header("access-control-request-method", "GET")
            .body(Body::empty())
            .expect("build preflight");

        let response = app.oneshot(request).await.expect("preflight should complete");

        assert!(
            response.headers().get("access-control-allow-origin").is_none(),
            "default console CORS must not emit Access-Control-Allow-Origin"
        );
        assert!(
            response.headers().get("access-control-allow-credentials").is_none(),
            "default console CORS must not emit Access-Control-Allow-Credentials"
        );
    }

    /// Operators that opt in to wildcard origins (via `*`) keep the previous
    /// permissive behavior.
    #[tokio::test]
    #[serial]
    async fn explicit_wildcard_console_cors_allows_any_origin() {
        let star = "*".to_string();
        let app = setup_console_middleware_stack(parse_cors_origins(Some(&star)), false, 0, 30);

        let request = Request::builder()
            .method("OPTIONS")
            .uri(format!("{CONSOLE_PREFIX}/license"))
            .header("origin", "https://example.com")
            .header("access-control-request-method", "GET")
            .body(Body::empty())
            .expect("build preflight");

        let response = app.oneshot(request).await.expect("preflight should complete");

        assert_eq!(
            response
                .headers()
                .get("access-control-allow-origin")
                .and_then(|v| v.to_str().ok()),
            Some("*"),
            "explicit `*` origin must produce wildcard Allow-Origin"
        );
    }

    /// Whitespace-padded wildcard ("` * `") must still be treated as wildcard
    /// rather than falling into the comma-separated parser. Common when the
    /// origin string is templated through env vars.
    #[tokio::test]
    #[serial]
    async fn whitespace_padded_wildcard_console_cors_allows_any_origin() {
        let star = " * ".to_string();
        let app = setup_console_middleware_stack(parse_cors_origins(Some(&star)), false, 0, 30);

        let request = Request::builder()
            .method("OPTIONS")
            .uri(format!("{CONSOLE_PREFIX}/license"))
            .header("origin", "https://example.com")
            .header("access-control-request-method", "GET")
            .body(Body::empty())
            .expect("build preflight");

        let response = app.oneshot(request).await.expect("preflight should complete");

        assert_eq!(
            response
                .headers()
                .get("access-control-allow-origin")
                .and_then(|v| v.to_str().ok()),
            Some("*"),
            "whitespace-padded `*` origin must produce wildcard Allow-Origin"
        );
    }

    // Mutates the global ENV_HEALTH_ENDPOINT_ENABLE env var; serialise to
    // avoid leaking the override into other async tests in the same module.
    #[tokio::test]
    #[serial]
    async fn console_middleware_stack_hides_health_routes_when_disabled() {
        async_with_vars([(rustfs_config::ENV_HEALTH_ENDPOINT_ENABLE, Some("false"))], async {
            let app = setup_console_middleware_stack(parse_cors_origins(None), false, 0, 30);

            let health_response = app
                .clone()
                .oneshot(
                    Request::builder()
                        .uri(format!("{CONSOLE_PREFIX}{HEALTH_PREFIX}"))
                        .body(Body::empty())
                        .expect("failed to build health request"),
                )
                .await
                .expect("health request should complete");
            assert_eq!(health_response.status(), StatusCode::NOT_FOUND);

            let readiness_response = app
                .oneshot(
                    Request::builder()
                        .uri(format!("{CONSOLE_PREFIX}{HEALTH_READY_PATH}"))
                        .body(Body::empty())
                        .expect("failed to build readiness request"),
                )
                .await
                .expect("readiness request should complete");
            assert_eq!(readiness_response.status(), StatusCode::NOT_FOUND);
        })
        .await;
    }

    #[tokio::test]
    async fn console_license_route_returns_public_status_only() {
        let app = setup_console_middleware_stack(parse_cors_origins(None), false, 0, 30);
        let request = Request::builder()
            .uri(format!("{CONSOLE_PREFIX}{LICENSE}"))
            .body(Body::empty())
            .expect("failed to build license request");

        let response = app.oneshot(request).await.expect("license request should complete");
        assert_eq!(response.status(), StatusCode::OK);

        let body = response
            .into_body()
            .collect()
            .await
            .expect("license body should collect")
            .to_bytes();
        let value: serde_json::Value = serde_json::from_slice(&body).expect("license response should be valid JSON");

        assert_eq!(value, serde_json::json!({ "licensed": false }));
        assert!(value.get("name").is_none());
        assert!(value.get("expired").is_none());
    }
}
