use crate::app::AppState;
use crate::context::RequestContext;
use crate::error::WebError;
use crate::routes::gateway::current_actor_id;
use crate::routes::local_folder_source::ensure_local_workspace_read_access_with_state;
use axum::extract::{Extension, Query, State};
use axum::http::StatusCode;
use axum::Json;
use control_plane::{NavigationRecentRecord, UpsertNavigationRecentInput};
use serde::Deserialize;
use serde_json::{json, Value};
use std::path::{Component, Path, PathBuf};

#[derive(Debug, Clone, Default, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct NavigationRecentListQuery {
    #[serde(default)]
    kind: Option<String>,
    #[serde(default)]
    limit: Option<usize>,
}

#[derive(Debug, Clone, Default, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct NavigationRecentUpsertRequest {
    #[serde(default)]
    id: Option<String>,
    #[serde(default, alias = "workspace_id")]
    workspace_id: Option<String>,
    #[serde(default)]
    kind: String,
    #[serde(default, alias = "source_kind")]
    source_kind: String,
    #[serde(default, alias = "root_uri")]
    root_uri: String,
    #[serde(default, alias = "relative_path")]
    relative_path: Option<String>,
    #[serde(default, alias = "document_id")]
    document_id: Option<String>,
    #[serde(default)]
    title: String,
    #[serde(default, alias = "metadata_json")]
    metadata_json: Option<String>,
}

pub async fn list_recent(
    State(state): State<AppState>,
    Extension(context): Extension<RequestContext>,
    Query(query): Query<NavigationRecentListQuery>,
) -> Result<Json<Value>, WebError> {
    let actor_id = require_actor_id(&state, &context)?;
    let records = load_navigation_recent_records(
        &state,
        &context,
        &actor_id,
        query.kind.as_deref(),
        query.limit.unwrap_or(40),
    );
    Ok(Json(json!({
        "ok": true,
        "owner": "mnote-web",
        "folders": records.iter().filter(|record| record.kind == "folder").map(recent_to_json).collect::<Vec<_>>(),
        "pages": records.iter().filter(|record| record.kind == "page").map(recent_to_json).collect::<Vec<_>>(),
        "items": records.iter().map(recent_to_json).collect::<Vec<_>>(),
    })))
}

pub async fn upsert_recent(
    State(state): State<AppState>,
    Extension(context): Extension<RequestContext>,
    Json(request): Json<NavigationRecentUpsertRequest>,
) -> Result<Json<Value>, WebError> {
    let actor_id = require_actor_id(&state, &context)?;
    let metadata_json = normalize_metadata_json(request.metadata_json)?;
    let source_kind = request
        .source_kind
        .trim()
        .to_string()
        .if_empty_else(|| "local_folder".to_string());
    let root_uri = request.root_uri.trim().to_string();
    let kind = request.kind.trim().to_string();
    let document_id = request
        .document_id
        .map(|value| value.trim().to_string())
        .filter(|value| !value.is_empty());
    let relative_path = normalize_navigation_relative_path(request.relative_path.as_deref())?
        .or_else(|| {
            document_id
                .as_deref()
                .and_then(local_markdown_relative_path_from_document_id)
        });
    if source_kind == "local_folder" {
        let root_path = ensure_local_workspace_read_access_with_state(&state, &context, &root_uri)?;
        ensure_navigation_recent_target_exists(&root_path, &kind, relative_path.as_deref())?;
    }
    let record = state
        .control_plane()
        .upsert_navigation_recent(UpsertNavigationRecentInput {
            id: request.id,
            user_id: actor_id,
            workspace_id: request
                .workspace_id
                .map(|value| value.trim().to_string())
                .filter(|value| !value.is_empty()),
            kind,
            source_kind,
            root_uri,
            relative_path,
            document_id,
            title: request.title.trim().to_string(),
            metadata_json,
        })
        .map_err(|error| {
            WebError::bad_request_code(
                "navigation_recent_upsert_failed",
                format!("写入最近访问失败: {error}"),
            )
            .with_context(&context)
        })?;
    Ok(Json(json!({
        "ok": true,
        "owner": "mnote-web",
        "recent": recent_to_json(&record),
    })))
}

pub(crate) fn load_navigation_recent_records(
    state: &AppState,
    context: &RequestContext,
    actor_id: &str,
    kind: Option<&str>,
    limit: usize,
) -> Vec<NavigationRecentRecord> {
    state
        .control_plane()
        .list_navigation_recent(actor_id, kind, limit)
        .unwrap_or_default()
        .into_iter()
        .filter(|record| navigation_recent_is_accessible(state, context, record))
        .collect()
}

fn navigation_recent_is_accessible(
    state: &AppState,
    context: &RequestContext,
    record: &NavigationRecentRecord,
) -> bool {
    if record.source_kind != "local_folder" {
        return true;
    }
    let Ok(root_path) =
        ensure_local_workspace_read_access_with_state(state, context, &record.root_uri)
    else {
        return false;
    };
    let relative_path = if record
        .relative_path
        .as_deref()
        .map(str::trim)
        .filter(|value| !value.is_empty())
        .is_some()
    {
        let Ok(relative_path) = normalize_navigation_relative_path(record.relative_path.as_deref())
        else {
            return false;
        };
        relative_path
    } else {
        record
            .document_id
            .as_deref()
            .and_then(local_markdown_relative_path_from_document_id)
    };
    let Ok(target_path) = relative_path
        .as_deref()
        .map(|path| canonical_navigation_target(&root_path, path))
        .transpose()
    else {
        return false;
    };
    match record.kind.as_str() {
        "folder" => target_path
            .as_deref()
            .map(Path::is_dir)
            .unwrap_or_else(|| root_path.is_dir()),
        "page" => target_path.as_deref().map(Path::is_file).unwrap_or(false),
        _ => true,
    }
}

fn ensure_navigation_recent_target_exists(
    root_path: &Path,
    kind: &str,
    relative_path: Option<&str>,
) -> Result<(), WebError> {
    match kind {
        "folder" => {
            if let Some(path) = relative_path {
                let target = canonical_navigation_target(root_path, path)?;
                if !target.is_dir() {
                    return Err(WebError::bad_request_code(
                        "navigation_recent_folder_not_found",
                        "最近访问文件夹不存在",
                    ));
                }
            }
            Ok(())
        }
        "page" => {
            let Some(path) = relative_path else {
                return Err(WebError::bad_request_code(
                    "navigation_recent_page_path_required",
                    "最近访问页面缺少相对路径",
                ));
            };
            let target = canonical_navigation_target(root_path, path)?;
            if !target.is_file() {
                return Err(WebError::bad_request_code(
                    "navigation_recent_page_not_found",
                    "最近访问页面不存在",
                ));
            }
            Ok(())
        }
        _ => Ok(()),
    }
}

fn canonical_navigation_target(root_path: &Path, relative_path: &str) -> Result<PathBuf, WebError> {
    let relative_path = normalize_navigation_relative_path(Some(relative_path))?
        .as_deref()
        .map(Path::new)
        .map(|path| root_path.join(path))
        .unwrap_or_else(|| root_path.to_path_buf());
    let canonical_target = relative_path.canonicalize().map_err(|error| {
        WebError::bad_request_code(
            "navigation_recent_target_unavailable",
            format!("最近访问目标不可用: {error}"),
        )
    })?;
    if !canonical_target.starts_with(root_path) {
        return Err(WebError::bad_request_code(
            "navigation_recent_root_escape",
            "最近访问路径不能越过授权目录",
        ));
    }
    Ok(canonical_target)
}

fn normalize_navigation_relative_path(value: Option<&str>) -> Result<Option<String>, WebError> {
    let Some(value) = value.map(str::trim).filter(|value| !value.is_empty()) else {
        return Ok(None);
    };
    let normalized = value.replace('\\', "/");
    let raw_path = Path::new(&normalized);
    if raw_path.is_absolute()
        || raw_path
            .components()
            .any(|component| matches!(component, Component::ParentDir))
    {
        return Err(WebError::bad_request_code(
            "navigation_recent_root_escape",
            "最近访问路径不能越过授权目录",
        ));
    }
    let cleaned = normalized
        .split('/')
        .filter(|segment| !segment.trim().is_empty() && *segment != ".")
        .collect::<Vec<_>>()
        .join("/");
    Ok(Some(cleaned).filter(|value| !value.is_empty()))
}

fn local_markdown_relative_path_from_document_id(document_id: &str) -> Option<String> {
    Some(
        document_id
            .trim()
            .strip_prefix("local-md:")?
            .replace("~2F", "/"),
    )
}

fn require_actor_id(state: &AppState, context: &RequestContext) -> Result<String, WebError> {
    current_actor_id(state, context)
        .filter(|actor_id| !actor_id.trim().is_empty() && actor_id.trim() != "anonymous")
        .ok_or_else(|| {
            WebError::new(
                StatusCode::UNAUTHORIZED,
                "navigation_recent_auth_required",
                "最近访问需要登录用户",
            )
            .with_context(context)
        })
}

fn recent_to_json(record: &NavigationRecentRecord) -> Value {
    json!({
        "id": record.id,
        "userId": record.user_id,
        "workspaceId": record.workspace_id,
        "kind": record.kind,
        "sourceKind": record.source_kind,
        "rootUri": record.root_uri,
        "relativePath": record.relative_path,
        "documentId": record.document_id,
        "title": record.title,
        "status": record.status,
        "metadata": serde_json::from_str::<Value>(&record.metadata_json).unwrap_or(Value::Null),
        "visitedAt": record.visited_at,
        "createdAt": record.created_at,
        "updatedAt": record.updated_at,
        "revision": record.revision,
    })
}

fn normalize_metadata_json(metadata_json: Option<String>) -> Result<String, WebError> {
    let metadata = metadata_json
        .as_deref()
        .map(str::trim)
        .filter(|value| !value.is_empty())
        .map(|value| serde_json::from_str::<Value>(value))
        .transpose()
        .map_err(|error| {
            WebError::bad_request_code(
                "navigation_recent_metadata_invalid",
                format!("最近访问 metadataJson 必须是 JSON 对象: {error}"),
            )
        })?
        .unwrap_or_else(|| json!({}));
    let normalized = if metadata.is_object() {
        metadata
    } else {
        json!({})
    };
    serde_json::to_string(&normalized)
        .map_err(|error| WebError::internal(format!("最近访问 metadataJson 序列化失败: {error}")))
}

trait EmptyStringExt {
    fn if_empty_else(self, fallback: impl FnOnce() -> String) -> String;
}

impl EmptyStringExt for String {
    fn if_empty_else(self, fallback: impl FnOnce() -> String) -> String {
        if self.trim().is_empty() {
            fallback()
        } else {
            self
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::app::{build_app, AppConfig, AppState};
    use axum::body::{to_bytes, Body};
    use axum::http::{header, Request, StatusCode};
    use control_plane::{UpsertNavigationRecentInput, UpsertUserInput};
    use tower::ServiceExt;

    fn test_state() -> AppState {
        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: false,
            enable_debug_shell_routes: false,
            enable_editor_actor: true,
            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(),
        })
    }

    fn temp_root(name: &str) -> std::path::PathBuf {
        let root = std::env::temp_dir().join(format!("{name}-{}", uuid::Uuid::new_v4().simple()));
        let _ = std::fs::remove_dir_all(&root);
        std::fs::create_dir_all(&root).expect("create temp root");
        root
    }

    #[tokio::test]
    async fn navigation_recent_requires_logged_in_actor() {
        let response = build_app(test_state())
            .oneshot(
                Request::builder()
                    .uri("/api/navigation/recent")
                    .body(Body::empty())
                    .expect("request"),
            )
            .await
            .expect("response");

        assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
    }

    #[tokio::test]
    async fn navigation_recent_post_and_get_returns_folder_and_page_groups() {
        let root = temp_root("mnote-navigation-recent-api");
        std::fs::create_dir_all(root.join("docs")).expect("create docs");
        std::fs::write(root.join("docs").join("Plan.md"), "# Plan\n").expect("write plan");
        let root_uri = format!("file://{}", root.display());
        crate::routes::local_folder_source::initialize_local_workspace_for_actor(
            "user_real",
            &root_uri,
        )
        .expect("init local workspace");
        let state = test_state();
        state
            .control_plane()
            .upsert_user(UpsertUserInput {
                id: Some("user_real".to_string()),
                email: Some("user_real@example.com".to_string()),
                username: "user_real".to_string(),
                display_name: "user_real".to_string(),
                role: None,
                password_hash: None,
            })
            .expect("upsert user");
        let app = build_app(state);

        let folder_body = json!({
            "kind": "folder",
            "sourceKind": "local_folder",
            "rootUri": root_uri,
            "relativePath": "docs",
            "title": "docs"
        })
        .to_string();
        let response = app
            .clone()
            .oneshot(
                Request::builder()
                    .method("POST")
                    .uri("/api/navigation/recent")
                    .header(header::CONTENT_TYPE, "application/json")
                    .header("x-mnote-actor-id", "user_real")
                    .header("x-mnote-actor-type", "user")
                    .body(Body::from(folder_body))
                    .expect("request"),
            )
            .await
            .expect("response");
        assert_eq!(response.status(), StatusCode::OK);

        let page_body = json!({
            "kind": "page",
            "sourceKind": "local_folder",
            "rootUri": root_uri,
            "relativePath": "docs/Plan.md",
            "documentId": "local-md:docs~2FPlan.md",
            "title": "Plan"
        })
        .to_string();
        let response = app
            .clone()
            .oneshot(
                Request::builder()
                    .method("POST")
                    .uri("/api/navigation/recent")
                    .header(header::CONTENT_TYPE, "application/json")
                    .header("x-mnote-actor-id", "user_real")
                    .header("x-mnote-actor-type", "user")
                    .body(Body::from(page_body))
                    .expect("request"),
            )
            .await
            .expect("response");
        assert_eq!(response.status(), StatusCode::OK);

        let response = app
            .oneshot(
                Request::builder()
                    .uri("/api/navigation/recent")
                    .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 payload: Value = serde_json::from_slice(&body).expect("json");
        assert_eq!(payload["folders"][0]["relativePath"], "docs");
        assert_eq!(payload["pages"][0]["documentId"], "local-md:docs~2FPlan.md");
        assert_eq!(payload["items"].as_array().map(Vec::len), Some(2));
    }

    #[tokio::test]
    async fn navigation_recent_list_filters_deleted_local_paths() {
        let root = temp_root("mnote-navigation-recent-filter-deleted");
        std::fs::create_dir_all(root.join("docs")).expect("create docs");
        std::fs::write(root.join("docs").join("Plan.md"), "# Plan\n").expect("write plan");
        let root_uri = format!("file://{}", root.display());
        crate::routes::local_folder_source::initialize_local_workspace_for_actor(
            "user_real",
            &root_uri,
        )
        .expect("init local workspace");
        let state = test_state();
        state
            .control_plane()
            .upsert_user(UpsertUserInput {
                id: Some("user_real".to_string()),
                email: Some("user_real@example.com".to_string()),
                username: "user_real".to_string(),
                display_name: "user_real".to_string(),
                role: None,
                password_hash: None,
            })
            .expect("upsert user");
        state
            .control_plane()
            .upsert_navigation_recent(UpsertNavigationRecentInput {
                id: None,
                user_id: "user_real".to_string(),
                workspace_id: None,
                kind: "page".to_string(),
                source_kind: "local_folder".to_string(),
                root_uri: root_uri.clone(),
                relative_path: Some("docs/Plan.md".to_string()),
                document_id: Some("local-md:docs~2FPlan.md".to_string()),
                title: "Plan".to_string(),
                metadata_json: "{}".to_string(),
            })
            .expect("insert recent");
        std::fs::remove_file(root.join("docs").join("Plan.md")).expect("delete plan");

        let response = build_app(state)
            .oneshot(
                Request::builder()
                    .uri("/api/navigation/recent")
                    .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 payload: Value = serde_json::from_slice(&body).expect("json");
        assert_eq!(payload["pages"].as_array().map(Vec::len), Some(0));
        assert_eq!(payload["items"].as_array().map(Vec::len), Some(0));
    }

    #[tokio::test]
    async fn navigation_recent_post_rejects_root_escape_relative_path() {
        let root = temp_root("mnote-navigation-recent-reject-escape");
        std::fs::create_dir_all(root.join("docs")).expect("create docs");
        let root_uri = format!("file://{}", root.display());
        crate::routes::local_folder_source::initialize_local_workspace_for_actor(
            "user_real",
            &root_uri,
        )
        .expect("init local workspace");
        let state = test_state();
        state
            .control_plane()
            .upsert_user(UpsertUserInput {
                id: Some("user_real".to_string()),
                email: Some("user_real@example.com".to_string()),
                username: "user_real".to_string(),
                display_name: "user_real".to_string(),
                role: None,
                password_hash: None,
            })
            .expect("upsert user");
        let app = build_app(state);
        let body = json!({
            "kind": "folder",
            "sourceKind": "local_folder",
            "rootUri": root_uri,
            "relativePath": "../",
            "title": "escape"
        })
        .to_string();

        let response = app
            .oneshot(
                Request::builder()
                    .method("POST")
                    .uri("/api/navigation/recent")
                    .header(header::CONTENT_TYPE, "application/json")
                    .header("x-mnote-actor-id", "user_real")
                    .header("x-mnote-actor-type", "user")
                    .body(Body::from(body))
                    .expect("request"),
            )
            .await
            .expect("response");

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

        assert_eq!(response.status(), StatusCode::BAD_REQUEST);
        assert_eq!(
            response
                .headers()
                .get("x-error-code")
                .and_then(|value| value.to_str().ok()),
            Some("navigation_recent_root_escape")
        );
    }

    #[tokio::test]
    async fn navigation_recent_list_filters_root_escape_records() {
        let root = temp_root("mnote-navigation-recent-filter-escape");
        std::fs::create_dir_all(root.join("docs")).expect("create docs");
        let root_uri = format!("file://{}", root.display());
        crate::routes::local_folder_source::initialize_local_workspace_for_actor(
            "user_real",
            &root_uri,
        )
        .expect("init local workspace");
        let state = test_state();
        state
            .control_plane()
            .upsert_user(UpsertUserInput {
                id: Some("user_real".to_string()),
                email: Some("user_real@example.com".to_string()),
                username: "user_real".to_string(),
                display_name: "user_real".to_string(),
                role: None,
                password_hash: None,
            })
            .expect("upsert user");
        state
            .control_plane()
            .upsert_navigation_recent(UpsertNavigationRecentInput {
                id: None,
                user_id: "user_real".to_string(),
                workspace_id: None,
                kind: "folder".to_string(),
                source_kind: "local_folder".to_string(),
                root_uri: root_uri.clone(),
                relative_path: Some("../".to_string()),
                document_id: None,
                title: "escape".to_string(),
                metadata_json: "{}".to_string(),
            })
            .expect("insert escaped recent through store");

        let response = build_app(state)
            .oneshot(
                Request::builder()
                    .uri("/api/navigation/recent")
                    .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 payload: Value = serde_json::from_slice(&body).expect("json");
        assert_eq!(payload["folders"].as_array().map(Vec::len), Some(0));
        assert_eq!(payload["items"].as_array().map(Vec::len), Some(0));
    }
}
