//! Integration tests for the htmx-ssr surface area.
//!
//! Three concerns covered here:
//!
//! 1. `render_route_response` dispatch matrix (M1, M3): how `(template,
//!    rendering_profile, HX-Request, HX-Boosted)` decides between full-shell,
//!    body fragment, template page, and template block paths.
//! 2. `/api/htmx/*` endpoints (M2): theme toggle, language switch, filter
//!    fragment — exercised through a real `axum_test::TestServer`.
//! 3. Template engine extension point (M3): registering an engine + dispatching
//!    a `kind=template` route end-to-end.
//!
//! Tests avoid the full `run_server()` boot because that pulls in the database,
//! TLS, NATS, etc. Each test wires only the router fragment it actually
//! exercises.

use std::collections::HashMap;
use std::sync::{Arc, OnceLock};

use axum::http::{HeaderMap, HeaderName, HeaderValue, StatusCode};
use axum::Router;
use axum_test::TestServer;
use leptos_config::LeptosOptions;
use rustelo_core_lib::routing::engine::{RouteMetadata, RouteResolution};
use rustelo_core_types::rendering::RenderingProfile;
use rustelo_server::api::htmx::htmx_routes;
use rustelo_server::api::{filter_routes, register_filter_renderer, FilterError, FilterQuery};
use rustelo_server::run::{
    register_fragment_renderer, register_template_engine, render_route_response,
};
use rustelo_server::templating::{register_filters, TemplateEngine, TemplateMode};

/// Shared LeptosOptions stub — these tests never actually render Leptos
/// components; `render_route_response` only passes it through to fallbacks.
fn leptos_opts() -> LeptosOptions {
    LeptosOptions::builder()
        .output_name("rustelo-tests")
        .build()
}

fn header(pairs: &[(&'static str, &str)]) -> HeaderMap {
    let mut h = HeaderMap::new();
    for (k, v) in pairs {
        match HeaderValue::from_str(v) {
            Ok(value) => {
                h.insert(HeaderName::from_static(k), value);
            }
            Err(e) => panic!("test header value `{v}` invalid: {e}"),
        }
    }
    h
}

fn resolution_for(
    path: &str,
    profile: RenderingProfile,
    template: Option<String>,
) -> RouteResolution {
    RouteResolution {
        path: path.to_string(),
        language: "en".to_string(),
        component: None,
        canonical_path: path.to_string(),
        metadata: RouteMetadata::default(),
        exists: true,
        parameters: HashMap::new(),
        rendering_profile: profile,
        template,
    }
}

// ---------------------------------------------------------------------------
// M1: profile-aware dispatch in render_route_response
// ---------------------------------------------------------------------------

// FRAGMENT_RENDERER lives in a OnceLock; once set during a test binary it
// cannot be unset. We register a tracking probe exactly once and inspect side
// effects.
static FRAGMENT_CALLS: OnceLock<std::sync::Mutex<Vec<String>>> = OnceLock::new();

fn fragment_calls() -> &'static std::sync::Mutex<Vec<String>> {
    FRAGMENT_CALLS.get_or_init(|| std::sync::Mutex::new(Vec::new()))
}

fn fragment_probe(_opts: LeptosOptions, resolution: &RouteResolution) -> String {
    let mut calls = fragment_calls()
        .lock()
        .expect("FRAGMENT_CALLS mutex must not be poisoned");
    calls.push(resolution.path.clone());
    format!(
        r#"<div data-fragment-for="{path}">FRAGMENT BODY</div>"#,
        path = resolution.path
    )
}

/// Returns true if the fragment renderer was invoked for the given path. Path
/// tracking (instead of a count delta) avoids cross-test races since each test
/// uses a unique path.
fn fragment_was_called_for(path: &str) -> bool {
    fragment_calls()
        .lock()
        .expect("FRAGMENT_CALLS mutex must not be poisoned")
        .iter()
        .any(|p| p == path)
}

fn ensure_fragment_renderer_registered() {
    static ONCE: std::sync::Once = std::sync::Once::new();
    ONCE.call_once(|| {
        register_fragment_renderer(fragment_probe);
    });
}

#[tokio::test]
async fn htmx_request_to_htmx_ssr_route_invokes_fragment_renderer() {
    ensure_fragment_renderer_registered();
    let path = "/m1/htmx-ssr-fragment";

    let resolution = resolution_for(path, RenderingProfile::HtmxSsr, None);
    let headers = header(&[("hx-request", "true")]);
    let resp = render_route_response(leptos_opts(), &resolution, &headers);

    assert_eq!(resp.status(), StatusCode::OK);
    assert!(
        fragment_was_called_for(path),
        "fragment renderer should be called for {path}"
    );

    let body = axum::body::to_bytes(resp.into_body(), 1024)
        .await
        .expect("read response body");
    let body_str = String::from_utf8_lossy(&body);
    assert!(body_str.contains("FRAGMENT BODY"));
    assert!(!body_str.contains("<!DOCTYPE"));
}

#[tokio::test]
async fn boosted_htmx_request_still_returns_full_shell() {
    ensure_fragment_renderer_registered();
    let path = "/m1/boosted-htmx";

    let resolution = resolution_for(path, RenderingProfile::HtmxSsr, None);
    let headers = header(&[("hx-request", "true"), ("hx-boosted", "true")]);
    let _resp = render_route_response(leptos_opts(), &resolution, &headers);

    assert!(
        !fragment_was_called_for(path),
        "boosted navigations must NOT trigger fragment renderer for {path}"
    );
}

#[tokio::test]
async fn leptos_hydration_route_ignores_hx_request() {
    ensure_fragment_renderer_registered();
    let path = "/m1/leptos-hydration-ignores-hx";

    let resolution = resolution_for(path, RenderingProfile::LeptosHydration, None);
    let headers = header(&[("hx-request", "true")]);
    let _resp = render_route_response(leptos_opts(), &resolution, &headers);

    assert!(
        !fragment_was_called_for(path),
        "leptos-hydration profile must NOT route to fragment renderer for {path}"
    );
}

// ---------------------------------------------------------------------------
// M2: /api/htmx/* endpoints via TestServer
// ---------------------------------------------------------------------------

fn htmx_app() -> Router {
    Router::new().nest("/api/htmx", htmx_routes())
}

#[tokio::test]
async fn theme_toggle_flips_cookie_and_returns_oob_html() {
    let app = htmx_app();
    let server = TestServer::new(app);

    let resp = server
        .post("/api/htmx/theme/toggle")
        .add_header(
            HeaderName::from_static("cookie"),
            HeaderValue::from_static("theme=light"),
        )
        .await;

    resp.assert_status_ok();
    let cookies = resp
        .iter_headers_by_name("set-cookie")
        .map(|v| v.to_str().expect("cookie must be ASCII").to_owned())
        .collect::<Vec<_>>();
    assert!(
        cookies.iter().any(|c| c.starts_with("theme=dark")),
        "expected theme=dark cookie, got: {:?}",
        cookies
    );

    let body = resp.text();
    assert!(body.contains("hx-post=\"/api/htmx/theme/toggle\""));
    assert!(body.contains("hx-swap-oob"));
}

#[tokio::test]
async fn theme_toggle_default_when_no_cookie() {
    let app = htmx_app();
    let server = TestServer::new(app);

    let resp = server.post("/api/htmx/theme/toggle").await;

    resp.assert_status_ok();
    let cookies = resp
        .iter_headers_by_name("set-cookie")
        .map(|v| v.to_str().expect("cookie must be ASCII").to_owned())
        .collect::<Vec<_>>();
    assert!(
        cookies.iter().any(|c| c.starts_with("theme=dark")),
        "no cookie should default to light, then flip to dark"
    );
}

#[tokio::test]
async fn lang_switch_rejects_unknown_code() {
    let app = htmx_app();
    let server = TestServer::new(app);

    let resp = server
        .post("/api/htmx/lang/zz")
        .add_header(
            HeaderName::from_static("hx-request"),
            HeaderValue::from_static("true"),
        )
        .await;

    resp.assert_status_bad_request();
    // htmx error fragments retarget to #htmx-error
    let retarget = resp
        .header(HeaderName::from_static("hx-retarget"))
        .to_str()
        .expect("HX-Retarget must be ASCII")
        .to_owned();
    assert_eq!(retarget, "#htmx-error");
}

fn filter_app() -> Router {
    static FILTER_REGISTERED: std::sync::Once = std::sync::Once::new();
    FILTER_REGISTERED.call_once(|| {
        register_filter_renderer(filter_probe);
    });
    Router::new().nest("/api/htmx/filter", filter_routes())
}

fn filter_probe(query: &FilterQuery) -> Result<String, FilterError> {
    if query.kind == "unknown" {
        return Err(FilterError::UnknownKind);
    }
    Ok(format!(
        r#"<ul data-kind="{kind}" data-category="{cat}" data-page="{page}"><li>item</li></ul>"#,
        kind = query.kind,
        cat = query.category.as_deref().unwrap_or("all"),
        page = query.page,
    ))
}

#[tokio::test]
async fn filter_endpoint_returns_renderer_output() {
    let app = filter_app();
    let server = TestServer::new(app);

    let resp = server.get("/api/htmx/filter/blog/rust").await;

    resp.assert_status_ok();
    let body = resp.text();
    assert!(body.contains(r#"data-kind="blog""#));
    assert!(body.contains(r#"data-category="rust""#));
    assert!(body.contains(r#"data-page="1""#));
}

#[tokio::test]
async fn filter_all_category_maps_to_none() {
    let app = filter_app();
    let server = TestServer::new(app);

    let resp = server.get("/api/htmx/filter/blog/all?page=3").await;

    resp.assert_status_ok();
    let body = resp.text();
    assert!(body.contains(r#"data-category="all""#));
    assert!(body.contains(r#"data-page="3""#));
}

#[tokio::test]
async fn filter_unknown_kind_returns_404_with_htmx_retarget() {
    let app = filter_app();
    let server = TestServer::new(app);

    let resp = server
        .get("/api/htmx/filter/unknown/foo")
        .add_header(
            HeaderName::from_static("hx-request"),
            HeaderValue::from_static("true"),
        )
        .await;

    resp.assert_status_not_found();
    let retarget = resp
        .header(HeaderName::from_static("hx-retarget"))
        .to_str()
        .expect("HX-Retarget must be ASCII")
        .to_owned();
    assert_eq!(retarget, "#htmx-error");
}

// ---------------------------------------------------------------------------
// M3: template route dispatch via TemplateEngine extension point
// ---------------------------------------------------------------------------

static TEMPLATE_REGISTERED: std::sync::Once = std::sync::Once::new();

fn ensure_template_engine_registered() {
    TEMPLATE_REGISTERED.call_once(|| {
        let engine = TemplateEngine::with_setup(
            TemplateMode::Embedded {
                templates: vec![(
                    "about.html",
                    r#"
<html><body>
<header>About — {{ language }}</header>
{% block content %}<main data-block="content"><h1>About body</h1><p>path={{ path }}</p></main>{% endblock %}
</body></html>
"#,
                )],
            },
            |env| register_filters(env),
        )
        .expect("template engine");
        register_template_engine(Arc::new(engine));
    });
}

#[tokio::test]
async fn template_route_renders_full_page_for_non_htmx_request() {
    ensure_template_engine_registered();

    let resolution = resolution_for(
        "/about",
        RenderingProfile::HtmxSsr,
        Some("about.html".into()),
    );
    let resp = render_route_response(leptos_opts(), &resolution, &HeaderMap::new());

    assert_eq!(resp.status(), StatusCode::OK);
    let body = axum::body::to_bytes(resp.into_body(), 4096)
        .await
        .expect("read response body");
    let body_str = String::from_utf8_lossy(&body);
    assert!(body_str.contains("<header>About — en</header>"));
    assert!(body_str.contains(r#"data-block="content""#));
    // minijinja autoescapes / → &#x2f; for any `.html` template. That is the
    // correct behavior for content-bearing variables rendered into HTML; if a
    // template author needs the literal slash they reach for the `safe` filter
    // explicitly. We assert against the escaped form so a regression that
    // silently disables autoescape is also caught.
    assert!(
        body_str.contains("path=&#x2f;about"),
        "expected escaped path in body, got: {body_str}"
    );
}

#[tokio::test]
async fn template_route_with_hx_target_renders_named_block() {
    ensure_template_engine_registered();

    let resolution = resolution_for(
        "/about",
        RenderingProfile::HtmxSsr,
        Some("about.html".into()),
    );
    let headers = header(&[("hx-request", "true"), ("hx-target", "#content")]);
    let resp = render_route_response(leptos_opts(), &resolution, &headers);

    assert_eq!(resp.status(), StatusCode::OK);
    let body = axum::body::to_bytes(resp.into_body(), 4096)
        .await
        .expect("read response body");
    let body_str = String::from_utf8_lossy(&body);
    // Fragment should be JUST the `content` block, not the full <html>.
    assert!(body_str.contains(r#"data-block="content""#));
    assert!(!body_str.contains("<header>"));
    assert!(!body_str.contains("<html>"));
}

#[tokio::test]
async fn template_route_boosted_request_renders_full_page() {
    ensure_template_engine_registered();

    let resolution = resolution_for(
        "/about",
        RenderingProfile::HtmxSsr,
        Some("about.html".into()),
    );
    let headers = header(&[("hx-request", "true"), ("hx-boosted", "true")]);
    let resp = render_route_response(leptos_opts(), &resolution, &headers);

    assert_eq!(resp.status(), StatusCode::OK);
    let body = axum::body::to_bytes(resp.into_body(), 4096)
        .await
        .expect("read response body");
    let body_str = String::from_utf8_lossy(&body);
    assert!(body_str.contains("<header>"));
}
