use crate::app::AppState;
use crate::context::RequestContext;
use crate::error::WebError;
use crate::routes::query_support::{
    execute_runtime_query_against_data, execute_runtime_query_via_convex,
    resolve_effective_workspace_id,
};
use crate::routes::web_shell::load_sidebar_tree_html;
use crate::ssr::pages::search::SearchPage;
use axum::extract::{Extension, Query, State};
use axum::http::{HeaderMap, HeaderName, HeaderValue, StatusCode};
use axum::response::{Html, IntoResponse, Response};
use axum::Json;
use bridge_runtime::RuntimeQueryEnvelopeWire;
use serde::Deserialize;
use serde_json::{json, Value};

const HEADER_MNOTE_WEB_OWNER: &str = "x-mnote-web-owner";
const HEADER_QUERY_NAME: &str = "x-query-name";

#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SearchDocumentsRequest {
    pub workspace_id: Option<String>,
    pub query: Option<String>,
    pub document_id: Option<String>,
    pub limit: Option<u32>,
    pub filters: Option<SearchDocumentsFilters>,
}

#[derive(Debug, Deserialize, Default)]
#[serde(rename_all = "camelCase")]
pub struct SearchDocumentsFilters {
    pub title_only: Option<bool>,
    pub exact: Option<bool>,
    pub include_ocr: Option<bool>,
    pub only_current_page: Option<bool>,
    pub time_range: Option<String>,
    pub time_field: Option<String>,
    pub custom_range: Option<SearchCustomRange>,
}

#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SearchCustomRange {
    pub from: Option<String>,
    pub to: Option<String>,
}

#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SearchShellQuery {
    pub workspace_id: Option<String>,
    pub q: Option<String>,
}

pub async fn shell(
    State(state): State<AppState>,
    Extension(context): Extension<RequestContext>,
    Query(query): Query<SearchShellQuery>,
) -> Result<Response, WebError> {
    let workspace_id = query
        .workspace_id
        .as_deref()
        .map(str::trim)
        .filter(|value| !value.is_empty())
        .unwrap_or("default");
    let sidebar_tree_html = load_sidebar_tree_html(state.config(), &context, workspace_id, None)
        .await
        .unwrap_or_default();
    let search_query = query.q.as_deref().map(str::trim).unwrap_or("");
    let initial_results = load_search_results(
        state.config(),
        &context,
        workspace_id,
        search_query,
        None,
        20,
    )
    .await
    .unwrap_or_else(|_| empty_search_projection());
    let contract = json!({
        "schema": "mnote.search_shell.v1",
        "owner": "mnote-web",
        "projectionOwner": initial_results.get("projectionOwner").cloned().unwrap_or_else(|| json!("rust-kernel")),
        "shell": "search",
        "workspaceId": workspace_id,
        "query": search_query,
        "initialResults": {
            "queryName": "search.documents.query",
            "projectionOwner": initial_results.get("projectionOwner").cloned().unwrap_or_else(|| json!("rust-kernel")),
            "results": initial_results.get("results").cloned().unwrap_or_else(|| Value::Array(vec![]))
        },
        "island": {
            "kind": "search_interaction_island",
            "mountId": "mnote-search-island",
            "runtime": "SearchPaletteHost"
        },
        "requestId": context.trace.request_id,
        "traceId": context.trace.trace_id
    });
    let contract_json = serde_json::to_string(&contract).unwrap_or_else(|_| "null".to_string());
    let body_content = crate::ssr::render_view(leptos::view! {
        <SearchPage
            workspace_id={workspace_id.to_string()}
            search_query={search_query.to_string()}
            sidebar_tree_html={sidebar_tree_html}
            initial_results_html={render_initial_results_html(initial_results.get("results").and_then(Value::as_array))}
        />
    });
    let html = format!(
        r#"<!doctype html>
<html lang="zh-CN">
  <head>
    <meta charset="utf-8">
    <title>搜索</title>
    <style>{}</style>
  </head>
  <body data-mnote-web-owner="mnote-web" data-mnote-shell="search" data-search-shell-owner="rust-web">
    {}
    <script id="__MNOTE_SEARCH_SHELL__" type="application/json">{}</script>
  </body>
</html>"#,
        crate::ssr::MNOTE_CSS,
        body_content,
        escape_script_json(&contract_json),
    );
    let mut response = Html(html).into_response();
    stamp_search_headers(response.headers_mut());
    if let Ok(name) = HeaderName::from_lowercase(b"x-mnote-web-shell") {
        response
            .headers_mut()
            .insert(name, HeaderValue::from_static("search"));
    }
    Ok(response)
}

pub async fn documents(
    State(state): State<AppState>,
    Extension(context): Extension<RequestContext>,
    Json(body): Json<SearchDocumentsRequest>,
) -> Result<(StatusCode, HeaderMap, Json<Value>), WebError> {
    let effective_workspace_id =
        resolve_effective_workspace_id(&context, body.workspace_id.as_deref(), true)?
            .expect("workspace_required 已确保存在");
    let filters = body.filters.unwrap_or_default();
    let normalized_query = body.query.unwrap_or_default().trim().to_string();
    let page_id = if filters.only_current_page.unwrap_or(false) {
        body.document_id.filter(|value| !value.trim().is_empty())
    } else {
        None
    };

    let result = load_search_results_with_filters(
        state.config(),
        &context,
        &effective_workspace_id,
        &normalized_query,
        page_id,
        body.limit.unwrap_or(30),
        filters,
    )
    .await?;

    let mut headers = HeaderMap::new();
    stamp_search_headers(&mut headers);
    Ok((
        StatusCode::OK,
        headers,
        Json(json!({
            "results": result.get("results").cloned().unwrap_or(Value::Array(vec![])),
            "projectionOwner": result.get("projectionOwner").cloned().unwrap_or_else(|| json!("rust-kernel")),
            "recent": [],
            "meta": {
                "owner": "mnote-web",
                "projectionOwner": result.get("projectionOwner").cloned().unwrap_or_else(|| json!("rust-kernel")),
                "queryName": "search.documents.query",
                "degraded": result.get("degraded").cloned().unwrap_or_else(|| json!(false)),
                "degradedReason": result.get("degradedReason").cloned().unwrap_or(Value::Null),
                "requestId": context.trace.request_id,
                "traceId": context.trace.trace_id,
            },
        })),
    ))
}

async fn load_search_results(
    config: &crate::app::AppConfig,
    context: &RequestContext,
    workspace_id: &str,
    query: &str,
    page_id: Option<String>,
    limit: u32,
) -> Result<Value, WebError> {
    load_search_results_with_filters(
        config,
        context,
        workspace_id,
        query,
        page_id,
        limit,
        SearchDocumentsFilters::default(),
    )
    .await
}

async fn load_search_results_with_filters(
    config: &crate::app::AppConfig,
    context: &RequestContext,
    workspace_id: &str,
    query: &str,
    page_id: Option<String>,
    limit: u32,
    filters: SearchDocumentsFilters,
) -> Result<Value, WebError> {
    if query.trim().is_empty() {
        return Ok(empty_search_projection());
    }
    let runtime_query = RuntimeQueryEnvelopeWire {
        name: "search.documents.query".into(),
        payload: json!({
            "query": query,
            "workspaceId": workspace_id,
            "pageId": page_id,
            "limit": limit,
            "titleOnly": filters.title_only.unwrap_or(false),
            "exact": filters.exact.unwrap_or(false),
            "includeOcr": filters.include_ocr.unwrap_or(false),
            "timeRange": filters.time_range.unwrap_or_else(|| "any".into()),
            "timeField": filters.time_field.unwrap_or_else(|| "updated".into()),
            "customRangeFrom": filters.custom_range.as_ref().and_then(|range| range.from.clone()),
            "customRangeTo": filters.custom_range.as_ref().and_then(|range| range.to.clone()),
        }),
    };
    match execute_runtime_query_via_convex(
        config,
        context,
        Some(workspace_id),
        runtime_query.clone(),
    )
    .await
    {
        Ok(value) => Ok(value),
        Err(_) if config.allow_dev_fixtures => execute_runtime_query_against_data(
            context,
            Some(workspace_id),
            runtime_query,
            fallback_search_dataset(workspace_id),
        ),
        Err(error) => Ok(degraded_empty_search_projection(error.message())),
    }
}

fn empty_search_projection() -> Value {
    json!({
        "enqueueAssetIds": [],
        "projectionOwner": "rust-kernel",
        "results": [],
    })
}

fn degraded_empty_search_projection(reason: &str) -> Value {
    json!({
        "enqueueAssetIds": [],
        "projectionOwner": "rust-kernel",
        "results": [],
        "degraded": true,
        "degradedReason": reason,
    })
}

fn fallback_search_dataset(workspace_id: &str) -> Value {
    json!({
        "documents": [
            {
                "id": "doc_1",
                "workspaceId": workspace_id,
                "title": "Rust Web 搜索结果",
                "rawText": "mnote-web search documents transport",
                "createdAt": "2026-04-28T00:00:00Z",
                "updatedAt": "2026-04-28T00:00:00Z"
            },
            {
                "id": "doc_hermes",
                "workspaceId": workspace_id,
                "title": "Hermes",
                "rawText": "Hermes 技能知识图谱开发 Wolai aline fixture",
                "createdAt": "2026-04-30T00:00:00Z",
                "updatedAt": "2026-04-30T00:00:00Z"
            },
            {
                "id": "doc_hermes_skill",
                "workspaceId": workspace_id,
                "title": "技能知识图谱开发",
                "rawText": "Hermes 页面路径 个人 软件开发",
                "createdAt": "2026-04-30T00:00:00Z",
                "updatedAt": "2026-04-30T00:00:00Z"
            }
        ],
        "mindmaps": [],
        "tables": [],
        "tableRows": [],
        "assets": []
    })
}

fn render_initial_results_html(results: Option<&Vec<Value>>) -> String {
    let Some(results) = results else {
        return r#"<div class="search-empty" data-search-empty="true">暂无结果</div>"#.into();
    };
    if results.is_empty() {
        return r#"<div class="search-empty" data-search-empty="true">暂无结果</div>"#.into();
    }
    let items = results
        .iter()
        .map(|item| {
            let title = item
                .get("title")
                .and_then(Value::as_str)
                .unwrap_or("无标题");
            let snippet = item
                .get("snippet")
                .and_then(Value::as_str)
                .unwrap_or_default();
            let href = item
                .get("publicPath")
                .and_then(Value::as_str)
                .unwrap_or("#");
            format!(
                r#"<a class="search-result" data-search-result-owner="rust-kernel" href="{}"><strong>{}</strong><span>{}</span></a>"#,
                escape_html(href),
                escape_html(title),
                escape_html(snippet)
            )
        })
        .collect::<Vec<_>>()
        .join("");
    format!(r#"<div class="search-result-list">{items}</div>"#)
}

fn escape_script_json(value: &str) -> String {
    value.replace("</script", "<\\/script")
}

fn escape_html(value: &str) -> String {
    value
        .replace('&', "&amp;")
        .replace('<', "&lt;")
        .replace('>', "&gt;")
        .replace('"', "&quot;")
}

fn stamp_search_headers(headers: &mut HeaderMap) {
    if let Ok(name) = HeaderName::from_lowercase(HEADER_MNOTE_WEB_OWNER.as_bytes()) {
        headers.insert(name, HeaderValue::from_static("mnote-web"));
    }
    if let Ok(name) = HeaderName::from_lowercase(HEADER_QUERY_NAME.as_bytes()) {
        headers.insert(name, HeaderValue::from_static("search.documents"));
    }
}

#[cfg(test)]
mod tests {
    use crate::app::{build_app, AppConfig, AppState};
    use axum::body::{to_bytes, Body};
    use axum::http::{Request, StatusCode};
    use serde_json::{json, Value};
    use tower::util::ServiceExt;

    fn app() -> axum::Router {
        build_app(AppState::new(AppConfig {
            service_name: "mnote-web".into(),
            service_version: "0.1.0".into(),
            bind_addr: "127.0.0.1:0".into(),
            public_bind_addr: "127.0.0.1:3000".into(),
            legacy_next_base_url: Some("http://127.0.0.1:3100".into()),
            enable_legacy_next_compat: true,
            enable_debug_shell_routes: false,
            hermes_base_path: "/api/hermes".into(),
            compat_next_base_path: "/api/compat/next".into(),
            convex_url: None,
            convex_admin_key: None,
            allow_dev_fixtures: true,
            query_fixtures_json: None,
            mutation_fixtures_json: None,
            dev_user_id: "dev-user".into(),
            dev_user_name: "开发用户".into(),
            dev_user_email: "dev@mnote.local".into(),
        }))
    }

    #[tokio::test]
    async fn search_shell_returns_server_first_island_contract() {
        let response = app()
            .oneshot(
                Request::builder()
                    .uri("/search?workspaceId=ws_demo&q=Rust")
                    .body(Body::empty())
                    .expect("request"),
            )
            .await
            .expect("response");

        assert_eq!(response.status(), StatusCode::OK);
        assert_eq!(
            response
                .headers()
                .get("x-mnote-web-owner")
                .and_then(|value| value.to_str().ok()),
            Some("mnote-web")
        );
        assert_eq!(
            response
                .headers()
                .get("x-mnote-web-shell")
                .and_then(|value| value.to_str().ok()),
            Some("search")
        );
        let body = to_bytes(response.into_body(), usize::MAX)
            .await
            .expect("body");
        let html = String::from_utf8(body.to_vec()).expect("utf8");
        assert!(html.contains("data-mnote-shell=\"search\""));
        assert!(html.contains("mnote.search_shell.v1"));
        assert!(html.contains("search_interaction_island"));
        assert!(html.contains("search.documents.query"));
        assert!(html.contains("search-result"));
        assert!(html.contains("Rust Web 搜索结果"));
        assert!(html.contains("data-mnote-dev-fixture=\"true\""));
        assert!(html.contains("data-mnote-dev-fixture-kind=\"sidebar-tree\""));
    }

    #[tokio::test]
    async fn search_documents_route_is_owned_by_mnote_web() {
        let response = app()
            .oneshot(
                Request::builder()
                    .method("POST")
                    .uri("/api/search/documents")
                    .header("content-type", "application/json")
                    .body(Body::from(
                        json!({
                            "workspaceId": "ws_demo",
                            "query": "Rust Web",
                            "filters": {
                                "titleOnly": false,
                                "exact": false,
                                "includeOcr": false,
                                "onlyCurrentPage": false,
                                "timeRange": "any",
                                "timeField": "updated"
                            }
                        })
                        .to_string(),
                    ))
                    .expect("request"),
            )
            .await
            .expect("response");

        assert_eq!(response.status(), StatusCode::OK);
        assert_eq!(
            response
                .headers()
                .get("x-mnote-web-owner")
                .and_then(|value| value.to_str().ok()),
            Some("mnote-web")
        );
        assert_eq!(
            response
                .headers()
                .get("x-query-name")
                .and_then(|value| value.to_str().ok()),
            Some("search.documents")
        );
        let body = to_bytes(response.into_body(), usize::MAX)
            .await
            .expect("body");
        let payload: Value = serde_json::from_slice(&body).expect("json");
        assert_eq!(payload["meta"]["owner"], "mnote-web");
        assert_eq!(payload["meta"]["queryName"], "search.documents.query");
        assert_eq!(payload["meta"]["projectionOwner"], "rust-kernel");
        assert_eq!(payload["projectionOwner"], "rust-kernel");
    }

    #[tokio::test]
    async fn search_documents_does_not_return_builtin_fixture_when_convex_unavailable() {
        let app = build_app(AppState::new(AppConfig {
            service_name: "mnote-web".into(),
            service_version: "0.1.0".into(),
            bind_addr: "127.0.0.1:0".into(),
            public_bind_addr: "127.0.0.1:3000".into(),
            legacy_next_base_url: Some("http://127.0.0.1:3100".into()),
            enable_legacy_next_compat: true,
            enable_debug_shell_routes: false,
            hermes_base_path: "/api/hermes".into(),
            compat_next_base_path: "/api/compat/next".into(),
            convex_url: None,
            convex_admin_key: None,
            allow_dev_fixtures: false,
            query_fixtures_json: None,
            mutation_fixtures_json: None,
            dev_user_id: "dev-user".into(),
            dev_user_name: "开发用户".into(),
            dev_user_email: "dev@mnote.local".into(),
        }));
        let response = app
            .oneshot(
                Request::builder()
                    .method("POST")
                    .uri("/api/search/documents")
                    .header("content-type", "application/json")
                    .body(Body::from(
                        json!({
                            "workspaceId": "ws_demo",
                            "query": "Hermes"
                        })
                        .to_string(),
                    ))
                    .expect("request"),
            )
            .await
            .expect("response");

        assert_eq!(response.status(), StatusCode::OK);
        let body = to_bytes(response.into_body(), usize::MAX)
            .await
            .expect("body");
        let payload: Value = serde_json::from_slice(&body).expect("json");
        assert_eq!(payload["results"], json!([]));
        assert_eq!(payload["meta"]["degraded"], true);
        assert!(!payload.to_string().contains("Hermes 技能知识图谱开发"));
    }
}
