use crate::app::AppState;
use crate::context::RequestContext;
use crate::error::WebError;
use crate::routes::local_folder_source::load_local_folder_page_tree_snapshot;
use crate::routes::web_shell::{
    build_document_panes_bootstrap_json, build_editor_bootstrap_json,
    build_page_aggregate_snapshot, escape_html, escape_script_json, load_file_tree_html,
    load_sidebar_tree_html, load_workspace_shell_projection,
    render_document_title_controller_script, render_editor_island_adapter_script,
    render_local_file_tree_html, render_local_sidebar_tree_html,
};
use crate::transport::convex::execute_convex_mutation_by_name;
use crate::workspace_shell::{
    build_workspace_shell_projection, render_workspace_shell_sidebar_html,
};
use axum::body::Body;
use axum::extract::ws::{Message as AxumWsMessage, WebSocket, WebSocketUpgrade};
use axum::extract::{Extension, Query, State};
use axum::http::{header, HeaderName, HeaderValue, Request, StatusCode, Uri};
use axum::response::{Html, IntoResponse, Response};
use futures_util::{SinkExt, StreamExt};
use serde::{Deserialize, Serialize};
use serde_json::json;
use std::time::Duration;
use tokio_tungstenite::tungstenite::Message as TungsteniteMessage;

const HEADER_MNOTE_WEB_OWNER: &str = "x-mnote-web-owner";
const HEADER_MNOTE_LEGACY_UPSTREAM: &str = "x-mnote-legacy-upstream";
const COOKIE_RECENT_PAGE_ID: &str = "mnote_recent_page_id";
const COOKIE_CONVEX_AUTH_JWT: &str = "__convexAuthJWT";
const COOKIE_CONVEX_AUTH_REFRESH_TOKEN: &str = "__convexAuthRefreshToken";
const COOKIE_MNOTE_WEB_CONVEX_TOKEN: &str = "mnote_web_convex_token";
const COOKIE_MNOTE_WEB_DEV_SESSION: &str = "mnote_web_dev_session";

#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
struct GatewayManifest {
    ok: bool,
    owner: &'static str,
    public_entry: String,
    legacy_next_base_url: Option<String>,
    legacy_next_compat_enabled: bool,
    notes: Vec<&'static str>,
}

#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct RootEntryQuery {
    page_id: Option<String>,
    workspace_id: Option<String>,
    source_kind: Option<String>,
    root_uri: Option<String>,
}

pub async fn gateway_health(State(state): State<AppState>) -> Response {
    let mut response = axum::Json(GatewayManifest {
        ok: true,
        owner: "mnote-web",
        public_entry: state.config().public_bind_addr.clone(),
        legacy_next_base_url: state.config().legacy_next_base_url.clone(),
        legacy_next_compat_enabled: state.config().enable_legacy_next_compat,
        notes: vec![
            "3000 公开入口默认由 mnote-web gateway 拥有。",
            "Next App Router 只作为 legacy compat upstream 使用。",
        ],
    })
    .into_response();
    stamp_gateway_headers(response.headers_mut(), false);
    response
}

pub async fn favicon() -> Response {
    let mut response = Response::builder()
        .status(StatusCode::NO_CONTENT)
        .body(Body::empty())
        .unwrap_or_else(|_| StatusCode::NO_CONTENT.into_response());
    stamp_gateway_headers(response.headers_mut(), false);
    response
}

pub async fn auth_api(
    State(state): State<AppState>,
    Extension(context): Extension<RequestContext>,
    request: Request<Body>,
) -> Result<Response, WebError> {
    if state.config().enable_legacy_next_compat && state.config().legacy_next_base_url.is_some() {
        return legacy_next_proxy(State(state), Extension(context), request).await;
    }

    let body = axum::body::to_bytes(request.into_body(), 256 * 1024)
        .await
        .map_err(|error| WebError::bad_request(format!("读取登录请求失败: {error}")))?;
    let payload: serde_json::Value = serde_json::from_slice(&body).map_err(|error| {
        WebError::bad_request_code(
            "auth_bad_request",
            format!("登录请求不是合法 JSON: {error}"),
        )
        .with_context(&context)
        .with_header(HEADER_MNOTE_WEB_OWNER, "mnote-web")
    })?;
    let action = payload
        .get("action")
        .and_then(|value| value.as_str())
        .unwrap_or_default();
    if action != "auth:signIn" && action != "auth:signOut" {
        return Err(WebError::bad_request_code(
            "auth_action_unsupported",
            "Rust gateway 当前仅支持 Convex Auth 登录与登出动作。",
        )
        .with_context(&context)
        .with_header(HEADER_MNOTE_WEB_OWNER, "mnote-web"));
    }

    let convex_response = run_convex_auth_action(&state, &context, &payload).await?;
    Ok(build_auth_proxy_response(&convex_response, &context))
}

pub async fn auth_entry(
    State(_state): State<AppState>,
    Extension(context): Extension<RequestContext>,
    _request: Request<Body>,
) -> Result<Response, WebError> {
    if has_real_auth_context(&context) {
        let mut response = Response::builder()
            .status(StatusCode::SEE_OTHER)
            .header(header::LOCATION, "/")
            .body(Body::empty())
            .map_err(|error| WebError::internal(format!("认证跳转响应构造失败: {error}")))?;
        stamp_gateway_headers(response.headers_mut(), false);
        return Ok(response);
    }

    let content = crate::ssr::render_view(crate::ssr::pages::auth::AuthPage());
    let mut response = Html(format!(
        r#"<!doctype html>
<html lang="zh-CN">
  <head>
    <meta charset="utf-8">
    <title>MNOTE Auth</title>
    <style>{}</style>
  </head>
  <body data-mnote-web-owner="mnote-web" data-mnote-shell="auth">
    {}
  </body>
</html>"#,
        crate::ssr::MNOTE_CSS,
        content
    ))
    .into_response();
    stamp_gateway_headers(response.headers_mut(), false);
    Ok(response)
}

pub async fn root_entry(
    State(state): State<AppState>,
    Extension(context): Extension<RequestContext>,
    Query(query): Query<RootEntryQuery>,
) -> Result<Response, WebError> {
    if !has_real_auth_context(&context) {
        let mut response = Response::builder()
            .status(StatusCode::SEE_OTHER)
            .header(header::LOCATION, "/auth")
            .body(Body::empty())
            .map_err(|error| WebError::internal(format!("认证入口跳转响应构造失败: {error}")))?;
        stamp_gateway_headers(response.headers_mut(), false);
        return Ok(response);
    }

    let is_local_folder = query
        .source_kind
        .as_deref()
        .map(str::trim)
        .filter(|value| !value.is_empty())
        == Some("local_folder");
    let requested_page_id = normalize_optional_id(query.page_id.as_deref());
    let recent_page_id = extract_cookie_value(&context, COOKIE_RECENT_PAGE_ID);
    let recent_page_id = normalize_optional_id(recent_page_id.as_deref());
    let default_workspace_name = format!("{} 的空间", state.config().dev_user_name);
    let (
        workspace_id,
        workspace_projection,
        sidebar_tree_html,
        file_tree_html,
        selected_active_page_id,
        active_source_kind,
        active_root_uri,
    ) = if is_local_folder {
        let root_uri = query
            .root_uri
            .as_deref()
            .map(str::trim)
            .filter(|value| !value.is_empty())
            .ok_or_else(|| {
                WebError::bad_request_code("local_folder_root_required", "缺少本地文件夹 rootUri")
            })?;
        let snapshot = load_local_folder_page_tree_snapshot(root_uri)?;
        let workspace_id = snapshot
            .dataset
            .get("workspace")
            .and_then(|workspace| workspace.get("id"))
            .and_then(|value| value.as_str())
            .map(str::trim)
            .filter(|value| !value.is_empty())
            .unwrap_or("local-folder")
            .to_string();
        let requested_or_recent_page_id = choose_root_entry_active_page_id(
            requested_page_id.clone(),
            recent_page_id.clone(),
            None,
            None,
        );
        let workspace_projection = build_workspace_shell_projection(
            &snapshot.dataset,
            &workspace_id,
            requested_or_recent_page_id.as_deref(),
            "本地文件夹",
        );
        let selected_active_page_id = choose_root_entry_active_page_id(
            requested_page_id.clone(),
            recent_page_id.clone(),
            workspace_projection.active_page_id.as_deref(),
            workspace_projection
                .my_page_items
                .first()
                .map(|item| item.id.as_str()),
        );
        let sidebar_tree_html =
            render_local_sidebar_tree_html(root_uri, selected_active_page_id.as_deref())?;
        let file_tree_html =
            render_local_file_tree_html(root_uri, selected_active_page_id.as_deref())?;
        (
            workspace_id,
            workspace_projection,
            sidebar_tree_html,
            file_tree_html,
            selected_active_page_id,
            Some("local_folder".to_string()),
            Some(root_uri.to_string()),
        )
    } else {
        let workspace_id =
            resolve_root_workspace_id(&state, &context, query.workspace_id.as_deref()).await?;
        let requested_or_recent_page_id = choose_root_entry_active_page_id(
            requested_page_id.clone(),
            recent_page_id.clone(),
            None,
            None,
        );
        let workspace_projection = load_workspace_shell_projection(
            state.config(),
            &context,
            &workspace_id,
            requested_or_recent_page_id.as_deref(),
            &default_workspace_name,
        )
        .await;
        let selected_active_page_id = choose_root_entry_active_page_id(
            requested_page_id.clone(),
            recent_page_id.clone(),
            workspace_projection.active_page_id.as_deref(),
            workspace_projection
                .my_page_items
                .first()
                .map(|item| item.id.as_str()),
        );
        let sidebar_tree_html = load_sidebar_tree_html(
            state.config(),
            &context,
            &workspace_id,
            selected_active_page_id.as_deref(),
        )
        .await
        .unwrap_or_default();
        let file_tree_html = load_file_tree_html(
            state.config(),
            &context,
            &workspace_id,
            selected_active_page_id.as_deref(),
        )
        .await
        .unwrap_or_default();
        (
            workspace_id,
            workspace_projection,
            sidebar_tree_html,
            file_tree_html,
            selected_active_page_id,
            None,
            None,
        )
    };
    let workspace_sidebar_html = render_workspace_shell_sidebar_html(
        &workspace_projection,
        Some(sidebar_tree_html.as_str()),
        Some(file_tree_html.as_str()),
    );
    let workspace_name = workspace_projection.workspace_name.clone();
    let active_page_id = selected_active_page_id.unwrap_or_default();
    let active_page_title = workspace_projection
        .active_page_title
        .clone()
        .unwrap_or_default();
    let render_workspace_entry = || {
        crate::ssr::render_view(leptos::view! {
            <crate::ssr::pages::home::HomePage
                sidebar_tree_html={sidebar_tree_html.clone()}
                workspace_name={workspace_name.clone()}
                workspace_id={workspace_id.clone()}
                workspace_sidebar_html={workspace_sidebar_html.clone()}
                active_page_id={active_page_id.clone()}
                active_page_title={active_page_title.clone()}
            />
        })
    };
    let (html_title, content, body_extra) = if active_page_id.trim().is_empty() {
        ("MNOTE".to_string(), render_workspace_entry(), String::new())
    } else {
        match build_page_aggregate_snapshot(
            &state,
            &context,
            &active_page_id,
            Some(workspace_id.as_str()),
            active_source_kind.as_deref(),
            active_root_uri.as_deref(),
        )
        .await
        {
            Ok(aggregate) => {
                let title = aggregate.head.title.as_str();
                let page_subtree_json = serde_json::to_string(&aggregate.tree.page_subtree)
                    .unwrap_or_else(|_| "null".to_string());
                let snapshot_json =
                    serde_json::to_string(&aggregate).unwrap_or_else(|_| "null".to_string());
                let bootstrap_json = build_editor_bootstrap_json(
                    &aggregate,
                    &context,
                    active_source_kind.as_deref(),
                    active_root_uri.as_deref(),
                );
                let panes_bootstrap_json = build_document_panes_bootstrap_json(
                    &aggregate,
                    &context,
                    active_source_kind.as_deref(),
                    active_root_uri.as_deref(),
                    None,
                    None,
                    None,
                    false,
                    false,
                );
                let content = crate::ssr::render_view(leptos::view! {
                    <crate::ssr::pages::document::DocumentPage
                        title={title.to_string()}
                        document_id={active_page_id.clone()}
                        workspace_id={workspace_id.clone()}
                        sidebar_tree_html={sidebar_tree_html.clone()}
                        workspace_name={workspace_name.clone()}
                        workspace_sidebar_html={workspace_sidebar_html.clone()}
                        page_subtree_json={page_subtree_json}
                    />
                });
                let body_extra = format!(
                    r#"<script id="__MNOTE_PAGE_AGGREGATE__" type="application/json">{}</script>
    <script id="__MNOTE_EDITOR_BOOTSTRAP__" type="application/json">{}</script>
    <script id="__MNOTE_DOCUMENT_PANES_BOOTSTRAP__" type="application/json">{}</script>
    {}
    {}"#,
                    escape_script_json(&snapshot_json),
                    escape_script_json(&bootstrap_json),
                    escape_script_json(&panes_bootstrap_json),
                    render_document_title_controller_script(),
                    render_editor_island_adapter_script(),
                );
                (title.to_string(), content, body_extra)
            }
            Err(_) => ("MNOTE".to_string(), render_workspace_entry(), String::new()),
        }
    };
    let mut response = 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="workspace">
    {}
    {}
  </body>
</html>"#,
        escape_html(&html_title),
        crate::ssr::MNOTE_CSS,
        content,
        body_extra
    ))
    .into_response();
    stamp_gateway_headers(response.headers_mut(), false);
    Ok(response)
}

pub async fn legacy_next_proxy(
    State(state): State<AppState>,
    Extension(context): Extension<RequestContext>,
    request: Request<Body>,
) -> Result<Response, WebError> {
    if !state.config().enable_legacy_next_compat {
        return Err(WebError::service_unavailable_code(
            "legacy_next_compat_disabled",
            "Next App Router legacy compat 已关闭，当前路径未迁到 Rust Web gateway。",
        )
        .with_context(&context)
        .with_header(HEADER_MNOTE_WEB_OWNER, "mnote-web"));
    }

    let Some(base_url) = state.config().legacy_next_base_url.as_deref() else {
        return Err(WebError::service_unavailable_code(
            "legacy_next_upstream_missing",
            "未配置 MNOTE_WEB_LEGACY_NEXT_BASE_URL，无法代理 legacy Next 路径。",
        )
        .with_context(&context)
        .with_header(HEADER_MNOTE_WEB_OWNER, "mnote-web"));
    };

    let path_and_query = request
        .uri()
        .path_and_query()
        .map(|value| value.as_str())
        .unwrap_or("/");
    let upstream_url = reqwest::Url::parse(&format!("{base_url}{path_and_query}"))
        .map_err(|error| WebError::internal(format!("legacy Next upstream URL 非法: {error}")))?;

    let method = request.method().clone();
    let headers = request.headers().clone();
    let body = axum::body::to_bytes(request.into_body(), 10 * 1024 * 1024)
        .await
        .map_err(|error| WebError::internal(format!("读取 legacy proxy 请求体失败: {error}")))?;
    let client = reqwest::Client::builder()
        .timeout(Duration::from_secs(30))
        .redirect(reqwest::redirect::Policy::none())
        .build()
        .map_err(|error| WebError::internal(format!("legacy Next HTTP 客户端创建失败: {error}")))?;

    let upstream_origin = upstream_origin(&upstream_url);
    let mut upstream_request = client.request(method, upstream_url);
    for (name, value) in headers.iter() {
        if is_hop_by_hop_header(name.as_str()) || name == header::HOST {
            continue;
        }
        if name == header::ORIGIN {
            upstream_request = upstream_request.header(name, upstream_origin.as_str());
            continue;
        }
        if name == header::REFERER {
            let normalized_referer = normalize_legacy_referer(value, &upstream_origin);
            upstream_request = upstream_request.header(name, normalized_referer);
            continue;
        }
        upstream_request = upstream_request.header(name, value);
    }

    let upstream_response = upstream_request
        .body(body.to_vec())
        .send()
        .await
        .map_err(|error| {
            WebError::bad_gateway_code(
                "legacy_next_proxy_error",
                format!("legacy Next 请求失败: {error}"),
            )
            .with_context(&context)
            .with_header(HEADER_MNOTE_WEB_OWNER, "mnote-web")
            .with_header(HEADER_MNOTE_LEGACY_UPSTREAM, "next-app-router")
        })?;

    let status = upstream_response.status();
    let upstream_headers = upstream_response.headers().clone();
    let body = upstream_response.bytes().await.map_err(|error| {
        WebError::bad_gateway_code(
            "legacy_next_proxy_body_error",
            format!("legacy Next 响应读取失败: {error}"),
        )
        .with_context(&context)
        .with_header(HEADER_MNOTE_WEB_OWNER, "mnote-web")
        .with_header(HEADER_MNOTE_LEGACY_UPSTREAM, "next-app-router")
    })?;

    let mut response = Response::builder()
        .status(StatusCode::from_u16(status.as_u16()).unwrap_or(StatusCode::BAD_GATEWAY))
        .body(Body::from(body))
        .map_err(|error| WebError::internal(format!("legacy proxy 响应构造失败: {error}")))?;
    for (name, value) in upstream_headers.iter() {
        if is_hop_by_hop_header(name.as_str()) {
            continue;
        }
        response.headers_mut().append(name, value.clone());
    }
    stamp_gateway_headers(response.headers_mut(), true);
    Ok(response)
}

#[allow(dead_code)]
pub async fn legacy_next_websocket_proxy(
    ws: WebSocketUpgrade,
    State(state): State<AppState>,
    Extension(context): Extension<RequestContext>,
    uri: Uri,
) -> Result<Response, WebError> {
    if !state.config().enable_legacy_next_compat {
        return Err(WebError::service_unavailable_code(
            "legacy_next_compat_disabled",
            "Next App Router legacy compat 已关闭，当前路径未迁到 Rust Web gateway。",
        )
        .with_context(&context)
        .with_header(HEADER_MNOTE_WEB_OWNER, "mnote-web"));
    }

    let Some(base_url) = state.config().legacy_next_base_url.as_deref() else {
        return Err(WebError::service_unavailable_code(
            "legacy_next_upstream_missing",
            "未配置 MNOTE_WEB_LEGACY_NEXT_BASE_URL，无法代理 legacy Next WebSocket。",
        )
        .with_context(&context)
        .with_header(HEADER_MNOTE_WEB_OWNER, "mnote-web"));
    };

    let upstream_url = build_legacy_next_ws_url(base_url, &uri)?;
    Ok(ws.on_upgrade(move |socket| async move {
        if let Err(error) = proxy_legacy_next_websocket(socket, upstream_url).await {
            tracing::warn!(error = %error, "legacy Next WebSocket 代理已断开");
        }
    }))
}

#[allow(dead_code)]
fn build_legacy_next_ws_url(base_url: &str, uri: &Uri) -> Result<String, WebError> {
    let upstream = reqwest::Url::parse(base_url)
        .map_err(|error| WebError::internal(format!("legacy Next upstream URL 非法: {error}")))?;
    let scheme = match upstream.scheme() {
        "https" => "wss",
        _ => "ws",
    };
    let host = upstream
        .host_str()
        .filter(|value| !value.trim().is_empty())
        .ok_or_else(|| WebError::internal("legacy Next upstream URL 缺少 host"))?;
    let host_with_port = match upstream.port() {
        Some(port) => format!("{host}:{port}"),
        None => host.to_string(),
    };
    let path_and_query = uri
        .path_and_query()
        .map(|value| value.as_str())
        .unwrap_or("/");
    Ok(format!("{scheme}://{host_with_port}{path_and_query}"))
}

#[allow(dead_code)]
async fn proxy_legacy_next_websocket(
    socket: WebSocket,
    upstream_url: String,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
    let (upstream, _) = tokio_tungstenite::connect_async(upstream_url.as_str()).await?;
    let (mut client_tx, mut client_rx) = socket.split();
    let (mut upstream_tx, mut upstream_rx) = upstream.split();

    let client_to_upstream = async {
        while let Some(message) = client_rx.next().await {
            let Ok(message) = message else {
                break;
            };
            if upstream_tx
                .send(axum_ws_to_tungstenite(message))
                .await
                .is_err()
            {
                break;
            }
        }
    };

    let upstream_to_client = async {
        while let Some(message) = upstream_rx.next().await {
            let Ok(message) = message else {
                break;
            };
            if client_tx
                .send(tungstenite_to_axum_ws(message))
                .await
                .is_err()
            {
                break;
            }
        }
    };

    tokio::select! {
        _ = client_to_upstream => {}
        _ = upstream_to_client => {}
    }

    Ok(())
}

#[allow(dead_code)]
fn axum_ws_to_tungstenite(message: AxumWsMessage) -> TungsteniteMessage {
    match message {
        AxumWsMessage::Text(value) => TungsteniteMessage::Text(value.to_string().into()),
        AxumWsMessage::Binary(value) => TungsteniteMessage::Binary(value),
        AxumWsMessage::Ping(value) => TungsteniteMessage::Ping(value),
        AxumWsMessage::Pong(value) => TungsteniteMessage::Pong(value),
        AxumWsMessage::Close(_) => TungsteniteMessage::Close(None),
    }
}

#[allow(dead_code)]
fn tungstenite_to_axum_ws(message: TungsteniteMessage) -> AxumWsMessage {
    match message {
        TungsteniteMessage::Text(value) => AxumWsMessage::Text(value.to_string().into()),
        TungsteniteMessage::Binary(value) => AxumWsMessage::Binary(value),
        TungsteniteMessage::Ping(value) => AxumWsMessage::Ping(value),
        TungsteniteMessage::Pong(value) => AxumWsMessage::Pong(value),
        TungsteniteMessage::Close(_) => AxumWsMessage::Close(None),
        TungsteniteMessage::Frame(_) => AxumWsMessage::Close(None),
    }
}

async fn resolve_root_workspace_id(
    state: &AppState,
    context: &RequestContext,
    requested_workspace_id: Option<&str>,
) -> Result<String, WebError> {
    if let Some(workspace_id) = normalize_optional_id(requested_workspace_id)
        .or_else(|| normalize_optional_id(context.workspace.workspace_id.as_deref()))
    {
        return Ok(workspace_id.to_string());
    }

    let bootstrap = execute_convex_mutation_by_name(
        state.config(),
        context,
        "workspaces:ensureDefaultWorkspace",
        json!({
            "fallbackName": state.config().dev_user_name,
            "workspaceIdIfCreate": format!("ws_{}", context.trace.request_id),
        }),
        None,
        None,
        "root_workspace_bootstrap",
    )
    .await?;

    bootstrap
        .get("activeWorkspaceId")
        .and_then(serde_json::Value::as_str)
        .and_then(|value| normalize_optional_id(Some(value)))
        .map(ToOwned::to_owned)
        .ok_or_else(|| {
            WebError::bad_gateway_code(
                "root_workspace_bootstrap_bad_response",
                "workspaces.ensureDefaultWorkspace 未返回 activeWorkspaceId",
            )
            .with_context(context)
            .with_header("x-error-phase", "root_workspace_bootstrap")
        })
}

fn normalize_optional_id(value: Option<&str>) -> Option<&str> {
    value.map(str::trim).filter(|value| !value.is_empty())
}

fn has_real_auth_context(context: &RequestContext) -> bool {
    let actor_id = context.auth.actor_id.trim();
    if !actor_id.is_empty() && actor_id != "anonymous" {
        return true;
    }

    extract_cookie_value(context, COOKIE_CONVEX_AUTH_JWT).is_some()
        || extract_cookie_value(context, COOKIE_MNOTE_WEB_CONVEX_TOKEN).is_some()
}

async fn run_convex_auth_action(
    state: &AppState,
    context: &RequestContext,
    payload: &serde_json::Value,
) -> Result<serde_json::Value, WebError> {
    let convex_url = state
        .config()
        .convex_url
        .as_deref()
        .map(str::trim)
        .filter(|value| !value.is_empty())
        .ok_or_else(|| {
            WebError::service_unavailable_code(
                "convex_config_missing",
                "缺少 CONVEX_SELF_HOSTED_URL / NEXT_PUBLIC_CONVEX_URL，无法执行 Convex Auth。",
            )
            .with_context(context)
            .with_header("x-error-phase", "convex_auth_url")
            .with_header("x-upstream-service", "convex")
        })?;
    let action = payload
        .get("action")
        .and_then(|value| value.as_str())
        .unwrap_or_default();
    let mut args = payload.get("args").cloned().unwrap_or_else(|| json!({}));
    if action == "auth:signIn"
        && args
            .get("refreshToken")
            .map(|value| !value.is_null())
            .unwrap_or(false)
    {
        if let Some(refresh_token) = extract_cookie_value(context, COOKIE_CONVEX_AUTH_REFRESH_TOKEN)
        {
            args["refreshToken"] = serde_json::Value::String(refresh_token);
        }
    }

    let request_body = json!({
        "path": action,
        "format": "convex_encoded_json",
        "args": [args],
    });
    let client = reqwest::Client::builder()
        .timeout(Duration::from_secs(20))
        .build()
        .map_err(|error| {
            WebError::internal(format!("Convex Auth HTTP 客户端创建失败: {error}"))
                .with_context(context)
                .with_header("x-error-phase", "convex_auth_client")
                .with_header("x-upstream-service", "convex")
        })?;
    let mut request = client
        .post(format!("{}/api/action", convex_url.trim_end_matches('/')))
        .header("Content-Type", "application/json")
        .header("Convex-Client", "mnote-web")
        .json(&request_body);
    if let Some(token) = extract_cookie_value(context, COOKIE_CONVEX_AUTH_JWT) {
        request = request.header(header::AUTHORIZATION, format!("Bearer {token}"));
    }

    let response = request.send().await.map_err(|error| {
        WebError::bad_gateway_code(
            "convex_auth_proxy_error",
            format!("Convex Auth 请求失败: {error}"),
        )
        .with_context(context)
        .with_header("x-error-phase", "convex_auth_action")
        .with_header("x-upstream-service", "convex")
    })?;
    let status = response.status();
    let value: serde_json::Value = response.json().await.map_err(|error| {
        WebError::bad_gateway_code(
            "convex_auth_response_invalid",
            format!("Convex Auth 响应不是合法 JSON: {error}"),
        )
        .with_context(context)
        .with_header("x-error-phase", "convex_auth_decode")
        .with_header("x-upstream-service", "convex")
    })?;
    if !status.is_success() && status.as_u16() != 560 {
        return Err(WebError::bad_gateway_code(
            "convex_auth_upstream_error",
            format!("Convex Auth 返回 HTTP {}: {}", status.as_u16(), value),
        )
        .with_context(context)
        .with_header("x-error-phase", "convex_auth_status")
        .with_header("x-upstream-service", "convex"));
    }
    Ok(value)
}

fn build_auth_proxy_response(
    convex_response: &serde_json::Value,
    context: &RequestContext,
) -> Response {
    if convex_response
        .get("status")
        .and_then(|value| value.as_str())
        != Some("success")
    {
        let message = convex_response
            .get("errorMessage")
            .and_then(|value| value.as_str())
            .unwrap_or("Convex Auth 登录失败。");
        let mut response = axum::Json(json!({ "error": message })).into_response();
        *response.status_mut() = StatusCode::BAD_REQUEST;
        clear_auth_cookies(response.headers_mut());
        stamp_gateway_headers(response.headers_mut(), false);
        context.apply_response_headers(response.headers_mut());
        return response;
    }

    let value = convex_response
        .get("value")
        .cloned()
        .unwrap_or_else(|| json!({}));
    let tokens = value.get("tokens");
    let mut response_body = value.clone();
    if let Some(tokens) = tokens {
        if tokens.is_null() {
            response_body["tokens"] = serde_json::Value::Null;
        } else if let Some(token) = tokens.get("token").and_then(|value| value.as_str()) {
            response_body["tokens"] = json!({
                "token": token,
                "refreshToken": "dummy",
            });
        }
    }

    let mut response = axum::Json(response_body).into_response();
    if let Some(tokens) = tokens {
        if tokens.is_null() {
            clear_auth_cookies(response.headers_mut());
        } else {
            set_auth_cookie_from_value(
                response.headers_mut(),
                COOKIE_CONVEX_AUTH_JWT,
                tokens.get("token"),
            );
            set_auth_cookie_from_value(
                response.headers_mut(),
                COOKIE_CONVEX_AUTH_REFRESH_TOKEN,
                tokens.get("refreshToken"),
            );
            expire_cookie(response.headers_mut(), COOKIE_MNOTE_WEB_DEV_SESSION);
        }
    }
    stamp_gateway_headers(response.headers_mut(), false);
    context.apply_response_headers(response.headers_mut());
    response
}

fn set_auth_cookie_from_value(
    headers: &mut axum::http::HeaderMap,
    name: &'static str,
    value: Option<&serde_json::Value>,
) {
    let Some(value) = value.and_then(|value| value.as_str()) else {
        return;
    };
    let cookie = format!("{name}={value}; Path=/; HttpOnly; SameSite=Lax");
    if let Ok(value) = HeaderValue::from_str(&cookie) {
        headers.append(header::SET_COOKIE, value);
    }
}

fn clear_auth_cookies(headers: &mut axum::http::HeaderMap) {
    expire_cookie(headers, COOKIE_CONVEX_AUTH_JWT);
    expire_cookie(headers, COOKIE_CONVEX_AUTH_REFRESH_TOKEN);
    expire_cookie(headers, COOKIE_MNOTE_WEB_DEV_SESSION);
}

fn expire_cookie(headers: &mut axum::http::HeaderMap, name: &'static str) {
    let cookie = format!("{name}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0");
    if let Ok(value) = HeaderValue::from_str(&cookie) {
        headers.append(header::SET_COOKIE, value);
    }
}

fn choose_root_entry_active_page_id(
    requested_page_id: Option<&str>,
    recent_page_id: Option<&str>,
    projection_active_page_id: Option<&str>,
    first_page_id: Option<&str>,
) -> Option<String> {
    normalize_optional_id(requested_page_id)
        .or_else(|| normalize_optional_id(recent_page_id))
        .or_else(|| normalize_optional_id(projection_active_page_id))
        .or_else(|| normalize_optional_id(first_page_id))
        .map(ToOwned::to_owned)
}

fn extract_cookie_value(context: &RequestContext, name: &str) -> Option<String> {
    context
        .auth
        .cookie_header
        .as_deref()?
        .split(';')
        .find_map(|part| {
            let (cookie_name, cookie_value) = part.trim().split_once('=')?;
            if cookie_name.trim() == name {
                let value = cookie_value.trim();
                if value.is_empty() {
                    None
                } else {
                    Some(value.to_string())
                }
            } else {
                None
            }
        })
}

fn stamp_gateway_headers(headers: &mut axum::http::HeaderMap, legacy: bool) {
    if let Ok(name) = HeaderName::from_lowercase(HEADER_MNOTE_WEB_OWNER.as_bytes()) {
        headers.insert(name, HeaderValue::from_static("mnote-web"));
    }
    if legacy {
        if let Ok(name) = HeaderName::from_lowercase(HEADER_MNOTE_LEGACY_UPSTREAM.as_bytes()) {
            headers.insert(name, HeaderValue::from_static("next-app-router"));
        }
    }
}

fn is_hop_by_hop_header(name: &str) -> bool {
    matches!(
        name.to_ascii_lowercase().as_str(),
        "connection"
            | "keep-alive"
            | "proxy-authenticate"
            | "proxy-authorization"
            | "te"
            | "trailers"
            | "transfer-encoding"
            | "upgrade"
    )
}

fn upstream_origin(url: &reqwest::Url) -> String {
    let host = url.host_str().unwrap_or("127.0.0.1");
    match url.port() {
        Some(port) => format!("{}://{}:{}", url.scheme(), host, port),
        None => format!("{}://{}", url.scheme(), host),
    }
}

fn normalize_legacy_referer(value: &HeaderValue, upstream_origin: &str) -> String {
    let referer = value.to_str().unwrap_or_default();
    let Ok(parsed) = reqwest::Url::parse(referer) else {
        return upstream_origin.to_string();
    };
    let path = parsed.path();
    let query = parsed
        .query()
        .map(|query| format!("?{query}"))
        .unwrap_or_default();
    format!("{upstream_origin}{path}{query}")
}

#[cfg(test)]
mod tests {
    use crate::app::{build_app, AppConfig, AppState};
    use axum::body::{to_bytes, Body};
    use axum::http::{header, HeaderMap, HeaderValue, Request, StatusCode};
    use axum::response::{Html, IntoResponse};
    use axum::routing::{get, post};
    use tokio::net::TcpListener;
    use tower::util::ServiceExt;

    fn app() -> axum::Router {
        app_with_legacy_next_base_url("http://127.0.0.1:3100".into())
    }

    fn app_with_legacy_next_base_url(legacy_next_base_url: String) -> axum::Router {
        app_with_config(legacy_next_base_url, true)
    }

    fn app_with_config(
        legacy_next_base_url: String,
        enable_legacy_next_compat: bool,
    ) -> axum::Router {
        app_with_config_and_convex_url(legacy_next_base_url, enable_legacy_next_compat, None)
    }

    fn app_with_config_and_convex_url(
        legacy_next_base_url: String,
        enable_legacy_next_compat: bool,
        convex_url: Option<String>,
    ) -> axum::Router {
        app_with_query_fixtures(
            legacy_next_base_url,
            enable_legacy_next_compat,
            convex_url,
            None,
        )
    }

    fn app_with_query_fixtures(
        legacy_next_base_url: String,
        enable_legacy_next_compat: bool,
        convex_url: Option<String>,
        query_fixtures_json: Option<String>,
    ) -> 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(legacy_next_base_url),
            enable_legacy_next_compat,
            enable_debug_shell_routes: false,
            hermes_base_path: "/api/hermes".into(),
            compat_next_base_path: "/api/compat/next".into(),
            convex_url,
            convex_admin_key: None,
            allow_dev_fixtures: true,
            query_fixtures_json,
            mutation_fixtures_json: Some(r#"{"workspaces:ensureDefaultWorkspace":{"workspaces":[{"id":"ws_demo","name":"我的空间","type":"personal","iconUrl":null,"memberCount":1,"isDefault":true}],"activeWorkspaceId":"ws_demo"}}"#.into()),
            dev_user_id: "dev-user".into(),
            dev_user_name: "开发用户".into(),
            dev_user_email: "dev@mnote.local".into(),
        }))
    }

    async fn spawn_convex_auth_upstream() -> String {
        let listener = TcpListener::bind("127.0.0.1:0")
            .await
            .expect("convex auth listener");
        let addr = listener.local_addr().expect("convex auth addr");
        let app = axum::Router::new().route(
            "/api/action",
            post(|| async {
                axum::Json(serde_json::json!({
                    "status": "success",
                    "value": {
                        "tokens": {
                            "token": "jwt-demo",
                            "refreshToken": "refresh-demo"
                        }
                    }
                }))
            }),
        );
        tokio::spawn(async move {
            axum::serve(listener, app)
                .await
                .expect("convex auth server");
        });
        format!("http://{addr}")
    }

    async fn spawn_legacy_auth_upstream() -> String {
        let listener = TcpListener::bind("127.0.0.1:0")
            .await
            .expect("legacy listener");
        let addr = listener.local_addr().expect("legacy addr");
        let app = axum::Router::new().route(
            "/auth",
            get(|| async {
                Html(r#"<html><body><button>测试账号快速登录</button></body></html>"#)
            })
            .post(|| async { "auth-post-ok" }),
        );
        tokio::spawn(async move {
            axum::serve(listener, app).await.expect("legacy server");
        });
        format!("http://{addr}")
    }

    async fn spawn_legacy_origin_checked_upstream() -> String {
        let listener = TcpListener::bind("127.0.0.1:0")
            .await
            .expect("legacy listener");
        let addr = listener.local_addr().expect("legacy addr");
        let expected_origin = format!("http://{addr}");
        let app = axum::Router::new().route(
            "/api/auth",
            post(move |headers: HeaderMap| {
                let expected_origin = expected_origin.clone();
                async move {
                    let origin = headers
                        .get("origin")
                        .and_then(|value| value.to_str().ok())
                        .unwrap_or_default();
                    if origin != expected_origin {
                        return (StatusCode::FORBIDDEN, "Invalid origin");
                    }
                    (StatusCode::OK, "ok")
                }
            }),
        );
        tokio::spawn(async move {
            axum::serve(listener, app).await.expect("legacy server");
        });
        format!("http://{addr}")
    }

    async fn spawn_legacy_cookie_upstream() -> String {
        let listener = TcpListener::bind("127.0.0.1:0")
            .await
            .expect("legacy listener");
        let addr = listener.local_addr().expect("legacy addr");
        let app = axum::Router::new().route(
            "/api/auth",
            post(|| async {
                let mut response = "ok".into_response();
                response.headers_mut().append(
                    header::SET_COOKIE,
                    HeaderValue::from_static("__convexAuthJWT=jwt-demo; Path=/; HttpOnly"),
                );
                response.headers_mut().append(
                    header::SET_COOKIE,
                    HeaderValue::from_static(
                        "__convexAuthRefreshToken=refresh-demo; Path=/; HttpOnly",
                    ),
                );
                response
            }),
        );
        tokio::spawn(async move {
            axum::serve(listener, app).await.expect("legacy server");
        });
        format!("http://{addr}")
    }

    async fn spawn_legacy_unmatched_upstream() -> String {
        let listener = TcpListener::bind("127.0.0.1:0")
            .await
            .expect("legacy listener");
        let addr = listener.local_addr().expect("legacy addr");
        let app = axum::Router::new().route("/unmigrated", get(|| async { "legacy-ok" }));
        tokio::spawn(async move {
            axum::serve(listener, app).await.expect("legacy server");
        });
        format!("http://{addr}")
    }

    #[tokio::test]
    async fn gateway_health_declares_mnote_web_owner() {
        let response = app()
            .oneshot(
                Request::builder()
                    .uri("/api/gateway/health")
                    .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")
        );

        let body = to_bytes(response.into_body(), usize::MAX)
            .await
            .expect("body");
        let payload: serde_json::Value = serde_json::from_slice(&body).expect("json");
        assert_eq!(payload["owner"], "mnote-web");
        assert_eq!(payload["publicEntry"], "127.0.0.1:3000");
        assert_eq!(payload["legacyNextBaseUrl"], "http://127.0.0.1:3100");
    }

    #[tokio::test]
    async fn root_entry_returns_wolai_workspace_layout_contract() {
        let response = app_with_config("http://127.0.0.1:3100".into(), false)
            .oneshot(
                Request::builder()
                    .uri("/")
                    .header("x-mnote-actor-id", "user_real")
                    .header("x-mnote-actor-type", "user")
                    .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")
        );
        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(r#"<body data-mnote-web-owner="mnote-web" data-mnote-shell="workspace">"#));
        assert!(html.contains(r#"data-testid="wolai-sidebar""#));
        assert!(html.contains(r#"data-testid="wolai-topbar""#));
        assert!(html.contains(r#"data-testid="wolai-floating-ai""#));
        assert!(html.contains("星标置顶"));
        assert!(html.contains("我的页面"));
        assert!(html.contains("垃圾箱"));
        assert!(html.contains("模板中心"));
        assert!(!html.contains("欢迎使用 MNOTE 知识管理平台"));
        assert!(!html.contains(r#"<a href="/documents">文档</a>"#));
    }

    #[test]
    fn root_entry_active_selection_prefers_page_id_over_recent_projection_and_first_page() {
        let selected = super::choose_root_entry_active_page_id(
            Some("page_query"),
            Some("page_recent"),
            Some("page_projection"),
            Some("page_first"),
        );
        assert_eq!(selected.as_deref(), Some("page_query"));
    }

    #[test]
    fn root_entry_active_selection_falls_back_to_recent_projection_first_then_empty() {
        let from_recent = super::choose_root_entry_active_page_id(
            None,
            Some("page_recent"),
            Some("page_projection"),
            Some("page_first"),
        );
        assert_eq!(from_recent.as_deref(), Some("page_recent"));

        let from_projection = super::choose_root_entry_active_page_id(
            Some(" "),
            None,
            Some("page_projection"),
            Some("page_first"),
        );
        assert_eq!(from_projection.as_deref(), Some("page_projection"));

        let from_first =
            super::choose_root_entry_active_page_id(None, None, None, Some("page_first"));
        assert_eq!(from_first.as_deref(), Some("page_first"));

        let empty = super::choose_root_entry_active_page_id(None, None, None, None);
        assert_eq!(empty, None);
    }

    #[tokio::test]
    async fn root_entry_uses_recent_page_cookie_as_active_page() {
        let response = app_with_config("http://127.0.0.1:3100".into(), false)
            .oneshot(
                Request::builder()
                    .uri("/")
                    .header("cookie", "mnote_recent_page_id=page_child")
                    .header("x-mnote-workspace-id", "ws_demo")
                    .header("x-mnote-actor-id", "user_real")
                    .header("x-mnote-actor-type", "user")
                    .body(Body::empty())
                    .expect("request"),
            )
            .await
            .expect("response");

        assert_eq!(response.status(), StatusCode::OK);
        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(r#"data-node-id="page_child""#));
        assert!(html.contains(r#"href="/documents/page_child?workspaceId=ws_demo""#));
        assert!(html.contains(r#"data-active="true""#));
        assert!(html.contains(r#"data-root-active-page-id="page_child""#));
    }

    #[tokio::test]
    async fn root_entry_active_page_includes_document_panes_bootstrap() {
        let response = app_with_query_fixtures(
            "http://127.0.0.1:3100".into(),
            false,
            None,
            Some(
                r#"{
                  "documents:getMeta": {
                    "id": "doc_1",
                    "workspace_id": "ws_demo",
                    "title": "服务端页面",
                    "updated_at": "2026-04-18T09:30:00Z",
                    "can_edit": true,
                    "word_count": 42,
                    "character_count": 128,
                    "block_count": 1
                  },
                  "documents:getContent": {
                    "content": [{"id": "block_1", "type": "paragraph", "content": []}],
                    "revision": 7,
                    "conflict_detection_key": "doc_1:7",
                    "pageSubtree": {"rootNodeId": "doc_1", "outline": []}
                  }
                }"#
                .into(),
            ),
        )
        .oneshot(
            Request::builder()
                .uri("/?pageId=doc_1&workspaceId=ws_demo")
                .header("x-mnote-actor-id", "user_real")
                .header("x-mnote-actor-type", "user")
                .body(Body::empty())
                .expect("request"),
        )
        .await
        .expect("response");

        assert_eq!(response.status(), StatusCode::OK);
        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("__MNOTE_PAGE_AGGREGATE__"));
        assert!(html.contains("__MNOTE_DOCUMENT_PANES_BOOTSTRAP__"));
        assert!(html.contains("mnote.document_panes_bootstrap.v1"));
    }

    #[tokio::test]
    async fn root_entry_renders_local_folder_without_debug_tree_route() {
        let root =
            std::env::temp_dir().join(format!("mnote-root-local-folder-{}", std::process::id()));
        let _ = std::fs::remove_dir_all(&root);
        std::fs::create_dir_all(root.join("docs")).expect("create local docs");
        std::fs::write(root.join("README.md"), "# Local Root\n正文\n").expect("write root md");
        std::fs::write(root.join("docs").join("child.md"), "# Child\n").expect("write child md");
        std::fs::write(root.join("plain.txt"), "plain\n").expect("write asset");

        let root_uri = format!("file://{}", root.display());
        let response = app_with_config("http://127.0.0.1:3100".into(), false)
            .oneshot(
                Request::builder()
                    .uri(format!(
                        "/?sourceKind=local_folder&rootUri={root_uri}&treeView=filetree"
                    ))
                    .header("x-mnote-actor-id", "user_real")
                    .header("x-mnote-actor-type", "user")
                    .body(Body::empty())
                    .expect("request"),
            )
            .await
            .expect("response");

        let _ = std::fs::remove_dir_all(&root);

        assert_eq!(response.status(), StatusCode::OK);
        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(r#"data-mnote-shell="workspace""#));
        assert!(html.contains("local_folder"));
        assert!(html.contains("Local Root"));
        assert!(html.contains(r#"data-row-id="local:folder:docs""#));
        assert!(html.contains(r#"data-row-id="local:asset:plain.txt""#));
        assert!(!html.contains("legacy_next_compat_disabled"));
        assert!(!html.contains(r#"href="/tree"#));
    }

    #[tokio::test]
    async fn root_entry_redirects_anonymous_viewer_to_auth() {
        let response = app_with_config("http://127.0.0.1:3100".into(), false)
            .oneshot(
                Request::builder()
                    .uri("/")
                    .body(Body::empty())
                    .expect("request"),
            )
            .await
            .expect("response");

        assert_eq!(response.status(), StatusCode::SEE_OTHER);
        assert_eq!(
            response
                .headers()
                .get(header::LOCATION)
                .and_then(|value| value.to_str().ok()),
            Some("/auth")
        );
        assert_eq!(
            response
                .headers()
                .get("x-mnote-web-owner")
                .and_then(|value| value.to_str().ok()),
            Some("mnote-web")
        );
    }

    #[tokio::test]
    async fn root_entry_allows_forwarded_actor_to_enter_workspace() {
        let response = app_with_config("http://127.0.0.1:3100".into(), false)
            .oneshot(
                Request::builder()
                    .uri("/")
                    .header("x-mnote-actor-id", "user_real")
                    .header("x-mnote-actor-type", "user")
                    .body(Body::empty())
                    .expect("request"),
            )
            .await
            .expect("response");

        assert_eq!(response.status(), StatusCode::OK);
        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(r#"data-mnote-shell="workspace""#));
    }

    #[tokio::test]
    async fn auth_entry_returns_gateway_fallback_shell_when_compat_disabled() {
        let response = app_with_config("http://127.0.0.1:3100".into(), false)
            .oneshot(
                Request::builder()
                    .uri("/auth")
                    .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!(response
            .headers()
            .get("content-type")
            .and_then(|value| value.to_str().ok())
            .unwrap_or_default()
            .contains("text/html"));
    }

    #[tokio::test]
    async fn favicon_is_handled_by_gateway_when_compat_disabled() {
        let response = app_with_config("http://127.0.0.1:3100".into(), false)
            .oneshot(
                Request::builder()
                    .uri("/favicon.ico")
                    .body(Body::empty())
                    .expect("request"),
            )
            .await
            .expect("response");

        assert_eq!(response.status(), StatusCode::NO_CONTENT);
        assert_eq!(
            response
                .headers()
                .get("x-mnote-web-owner")
                .and_then(|value| value.to_str().ok()),
            Some("mnote-web")
        );
    }

    #[tokio::test]
    async fn auth_api_sets_convex_auth_cookies_when_compat_disabled() {
        let convex_url = spawn_convex_auth_upstream().await;
        let response = app_with_config_and_convex_url(
            "http://127.0.0.1:3100".into(),
            false,
            Some(convex_url),
        )
            .oneshot(
                Request::builder()
                    .method("POST")
                    .uri("/api/auth")
                    .header("content-type", "application/json")
                    .body(Body::from(
                        r#"{"action":"auth:signIn","args":{"provider":"password","params":{"email":"mnote.e2e@example.com","password":"MnoteE2E123!","flow":"signIn"}}}"#,
                    ))
                    .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")
        );
        let cookies = response.headers().get_all(header::SET_COOKIE);
        let values = cookies
            .iter()
            .map(|value| value.to_str().unwrap_or_default())
            .collect::<Vec<_>>();
        assert!(values
            .iter()
            .any(|value| value.contains("__convexAuthJWT=jwt-demo")));
        assert!(values
            .iter()
            .any(|value| value.contains("__convexAuthRefreshToken=refresh-demo")));
        let body = to_bytes(response.into_body(), usize::MAX)
            .await
            .expect("body");
        let payload: serde_json::Value = serde_json::from_slice(&body).expect("json");
        assert_eq!(payload["tokens"]["token"], "jwt-demo");
        assert_eq!(payload["tokens"]["refreshToken"], "dummy");
    }

    #[tokio::test]
    async fn auth_entry_uses_mnote_web_login_ui_when_compat_enabled() {
        let legacy_base_url = spawn_legacy_auth_upstream().await;
        let response = app_with_legacy_next_base_url(legacy_base_url)
            .oneshot(
                Request::builder()
                    .uri("/auth")
                    .body(Body::empty())
                    .expect("request"),
            )
            .await
            .expect("response");

        assert_eq!(response.status(), StatusCode::OK);
        assert_eq!(
            response
                .headers()
                .get("x-mnote-legacy-upstream")
                .and_then(|value| value.to_str().ok()),
            None
        );
        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(r#"data-mnote-shell="auth""#));
        assert!(html.contains("邮箱登录"));
        assert!(html.contains("测试账号快速登录"));
    }

    #[tokio::test]
    async fn auth_entry_redirects_authenticated_viewer_to_root() {
        let response = app_with_config("http://127.0.0.1:3100".into(), false)
            .oneshot(
                Request::builder()
                    .uri("/auth")
                    .header("x-mnote-actor-id", "user_real")
                    .header("x-mnote-actor-type", "user")
                    .body(Body::empty())
                    .expect("request"),
            )
            .await
            .expect("response");

        assert_eq!(response.status(), StatusCode::SEE_OTHER);
        assert_eq!(
            response
                .headers()
                .get(header::LOCATION)
                .and_then(|value| value.to_str().ok()),
            Some("/")
        );
    }

    #[tokio::test]
    async fn legacy_proxy_normalizes_auth_post_origin_to_upstream_origin() {
        let legacy_base_url = spawn_legacy_origin_checked_upstream().await;
        let response = app_with_legacy_next_base_url(legacy_base_url)
            .oneshot(
                Request::builder()
                    .method("POST")
                    .uri("/api/auth")
                    .header("origin", "http://127.0.0.1:3000")
                    .header("referer", "http://127.0.0.1:3000/auth")
                    .body(Body::from(r#"{"action":"auth:signIn"}"#))
                    .expect("request"),
            )
            .await
            .expect("response");

        assert_eq!(response.status(), StatusCode::OK);
        assert_eq!(
            response
                .headers()
                .get("x-mnote-legacy-upstream")
                .and_then(|value| value.to_str().ok()),
            Some("next-app-router")
        );
    }

    #[tokio::test]
    async fn legacy_proxy_preserves_multiple_set_cookie_headers() {
        let legacy_base_url = spawn_legacy_cookie_upstream().await;
        let response = app_with_legacy_next_base_url(legacy_base_url)
            .oneshot(
                Request::builder()
                    .method("POST")
                    .uri("/api/auth")
                    .body(Body::empty())
                    .expect("request"),
            )
            .await
            .expect("response");

        assert_eq!(response.status(), StatusCode::OK);
        let cookies = response.headers().get_all("set-cookie");
        let values = cookies
            .iter()
            .map(|value| value.to_str().unwrap_or_default())
            .collect::<Vec<_>>();
        assert!(values
            .iter()
            .any(|value| value.contains("__convexAuthJWT=jwt-demo")));
        assert!(values
            .iter()
            .any(|value| value.contains("__convexAuthRefreshToken=refresh-demo")));
    }

    #[tokio::test]
    async fn unmigrated_route_returns_not_found_instead_of_proxying_to_legacy_next() {
        let legacy_base_url = spawn_legacy_unmatched_upstream().await;
        let response = app_with_legacy_next_base_url(legacy_base_url)
            .oneshot(
                Request::builder()
                    .uri("/unmigrated")
                    .body(Body::empty())
                    .expect("request"),
            )
            .await
            .expect("response");

        assert_eq!(response.status(), StatusCode::NOT_FOUND);
        assert_eq!(
            response
                .headers()
                .get("x-mnote-legacy-upstream")
                .and_then(|value| value.to_str().ok()),
            None
        );
    }
}
