// SPDX-License-Identifier: Apache-2.0
//! API request types for all HTTP endpoints.

use serde::{Deserialize, Serialize};
use std::collections::HashMap;

// ===== Memory CRUD =====

#[derive(Debug, Serialize, Deserialize)]
pub struct StoreMemoryRequest {
    pub content: String,
    #[serde(default)]
    pub memory_type: Option<String>,
    #[serde(default, alias = "domain")]
    pub space: Option<String>,
    #[serde(default)]
    pub source_agent: Option<String>,
    #[serde(default)]
    pub title: Option<String>,
    #[serde(default)]
    pub confidence: Option<f32>,
    #[serde(default)]
    pub supersedes: Option<String>,
    /// Entity name for resolution (e.g. "Alice", "PostgreSQL")
    #[serde(default)]
    pub entity: Option<String>,
    /// Direct entity ID (bypasses name resolution)
    #[serde(default)]
    pub entity_id: Option<String>,
    #[serde(default)]
    pub structured_fields: Option<serde_json::Value>,
    #[serde(default)]
    pub retrieval_cue: Option<String>,
}

#[derive(Debug, Serialize, Deserialize)]
pub struct SearchMemoryRequest {
    pub query: String,
    #[serde(default = "default_limit")]
    pub limit: usize,
    #[serde(default)]
    pub memory_type: Option<String>,
    #[serde(default, alias = "domain")]
    pub space: Option<String>,
    #[serde(default)]
    pub source_agent: Option<String>,
    /// When `true` AND the daemon has a reranker wired (via
    /// `ORIGIN_RERANKER_ENABLED=1`), results pass through a cross-encoder
    /// reranker after the embedding+FTS hybrid step. When `true` but no
    /// reranker is available, the daemon logs a warning and falls back to
    /// the plain hybrid ordering. Default `false` to preserve current
    /// behavior for callers that don't opt in.
    #[serde(default)]
    pub rerank: bool,
}

#[derive(Debug, Serialize, Deserialize)]
pub struct ListMemoriesRequest {
    #[serde(default)]
    pub memory_type: Option<String>,
    #[serde(default, alias = "domain")]
    pub space: Option<String>,
    #[serde(default = "default_list_limit")]
    pub limit: usize,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub confirmed: Option<bool>,
}

#[derive(Debug, Serialize, Deserialize)]
pub struct ConfirmRequest {
    #[serde(default = "default_confirmed")]
    pub confirmed: bool,
}

#[derive(Debug, Serialize, Deserialize)]
pub struct ReclassifyMemoryRequest {
    pub memory_type: String,
}

#[derive(Debug, Serialize, Deserialize)]
pub struct ImportMemoriesRequest {
    pub source: String,
    pub content: String,
    #[serde(default)]
    pub label: Option<String>,
}

// ===== General search/context =====

#[derive(Debug, Serialize, Deserialize)]
pub struct SearchRequest {
    pub query: String,
    #[serde(default = "default_limit")]
    pub limit: usize,
    pub source_filter: Option<String>,
    #[serde(default, alias = "domain")]
    pub space: Option<String>,
}

#[doc(hidden)]
#[derive(Debug, Serialize, Deserialize)]
pub struct ContextRequest {
    pub current_file: String,
    pub cursor_prefix: String,
    #[serde(default = "default_limit")]
    pub limit: usize,
}

#[derive(Debug, Serialize, Deserialize)]
pub struct ChatContextRequest {
    #[serde(default)]
    pub query: Option<String>,
    #[serde(default)]
    pub conversation_id: Option<String>,
    #[serde(default = "default_max_chunks")]
    pub max_chunks: usize,
    #[serde(default)]
    pub relevance_threshold: Option<f64>,
    /// Deprecated: goal-typed memories were folded into identity by migration 45
    /// (Phase 0). Daemon ignores this field — no goal-load path exists anymore.
    /// Field stays for wire backward compat; will be removed in 0.4.
    #[deprecated(
        since = "0.3.2",
        note = "Goal taxonomy folded into Identity by migration 45 (Phase 0). \
                Daemon ignores this field. Will be removed in 0.4."
    )]
    #[serde(default = "default_true")]
    pub include_goals: bool,
    #[serde(default, alias = "domain")]
    pub space: Option<String>,
}

// ===== Knowledge graph =====

#[derive(Debug, Serialize, Deserialize)]
pub struct CreateEntityRequest {
    pub name: String,
    pub entity_type: String,
    #[serde(default, alias = "domain")]
    pub space: Option<String>,
    #[serde(default)]
    pub source_agent: Option<String>,
    #[serde(default)]
    pub confidence: Option<f32>,
}

#[doc(hidden)]
#[derive(Debug, Serialize, Deserialize)]
pub struct CreateRelationRequest {
    pub from_entity: String,
    pub to_entity: String,
    pub relation_type: String,
    #[serde(default)]
    pub source_agent: Option<String>,
    #[serde(default)]
    pub confidence: Option<f64>,
    #[serde(default)]
    pub explanation: Option<String>,
    #[serde(default)]
    pub source_memory_id: Option<String>,
}

#[derive(Debug, Serialize, Deserialize)]
pub struct AddObservationRequest {
    pub entity_id: String,
    pub content: String,
    #[serde(default)]
    pub source_agent: Option<String>,
    #[serde(default)]
    pub confidence: Option<f32>,
}

#[doc(hidden)]
#[derive(Debug, Serialize, Deserialize)]
pub struct LinkEntityRequest {
    pub source_id: String,
    pub entity_id: String,
}

#[derive(Debug, Serialize, Deserialize)]
pub struct ListEntitiesRequest {
    #[serde(default)]
    pub entity_type: Option<String>,
    #[serde(default, alias = "domain")]
    pub space: Option<String>,
}

#[derive(Debug, Serialize, Deserialize)]
pub struct SearchEntitiesRequest {
    pub query: String,
    #[serde(default = "default_entity_search_limit")]
    pub limit: usize,
}

// ===== Profile & Agents =====

#[derive(Debug, Serialize, Deserialize)]
pub struct UpdateProfileRequest {
    #[serde(default)]
    pub name: Option<String>,
    #[serde(default)]
    pub display_name: Option<String>,
    #[serde(default)]
    pub email: Option<String>,
    #[serde(default)]
    pub bio: Option<String>,
    #[serde(default)]
    pub avatar_path: Option<String>,
}

#[derive(Debug, Serialize, Deserialize)]
pub struct UpdateAgentRequest {
    #[serde(default)]
    pub agent_type: Option<String>,
    #[serde(default)]
    pub description: Option<String>,
    #[serde(default)]
    pub enabled: Option<bool>,
    #[serde(default)]
    pub trust_level: Option<String>,
    /// Empty string clears the field; None leaves it unchanged.
    #[serde(default)]
    pub display_name: Option<String>,
}

// ===== Spaces =====

#[derive(Debug, Serialize, Deserialize)]
pub struct CreateSpaceRequest {
    pub name: String,
    pub description: Option<String>,
}

#[derive(Debug, Serialize, Deserialize)]
pub struct UpdateSpaceRequest {
    pub new_name: Option<String>,
    pub description: Option<String>,
}

// ===== Concepts =====

#[doc(hidden)]
#[derive(Debug, Serialize, Deserialize)]
pub struct CreateConceptRequest {
    pub title: String,
    pub content: String,
    #[serde(default)]
    pub summary: Option<String>,
    #[serde(default)]
    pub entity_id: Option<String>,
    #[serde(default, alias = "domain")]
    pub space: Option<String>,
    #[serde(default)]
    pub source_memory_ids: Vec<String>,
    #[serde(default)]
    pub creation_kind: Option<String>,
    /// Dedicated workspace axis (P3). When Some, persisted to `pages.workspace`.
    /// Distinct from `space` (category column used for page_type filtering).
    #[serde(default)]
    pub workspace: Option<String>,
}

#[derive(Debug, Serialize, Deserialize)]
pub struct SearchPagesRequest {
    pub query: String,
    #[serde(default)]
    pub limit: Option<usize>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub page_type: Option<String>,
}

// ===== Ingest =====

#[derive(Debug, Serialize, Deserialize)]
pub struct IngestTextRequest {
    pub source: String,
    pub source_id: String,
    pub title: String,
    pub content: String,
    pub url: Option<String>,
    pub metadata: Option<HashMap<String, String>>,
}

#[doc(hidden)]
#[derive(Debug, Serialize, Deserialize)]
pub struct IngestWebpageRequest {
    pub url: String,
    pub title: String,
    pub content: String,
    pub metadata: Option<HashMap<String, String>>,
}

#[derive(Debug, Serialize, Deserialize)]
pub struct IngestMemoryRequest {
    pub source: String,
    pub source_id: String,
    pub title: String,
    pub content: String,
    pub url: Option<String>,
    pub tags: Option<Vec<String>>,
    pub metadata: Option<HashMap<String, String>>,
}

// ===== Sources =====

#[doc(hidden)]
#[derive(Debug, Serialize, Deserialize)]
pub struct AddSourceRequest {
    pub source_type: String,
    /// Filesystem path as a string. Kept as `String` (not `PathBuf`) because
    /// this is an HTTP wire format — the handler converts it to `PathBuf`.
    pub path: String,
}

// ===== Config =====

#[derive(Debug, Serialize, Deserialize)]
pub struct UpdateConfigRequest {
    #[serde(default)]
    pub skip_apps: Option<Vec<String>>,
    #[serde(default)]
    pub skip_title_patterns: Option<Vec<String>>,
    #[serde(default)]
    pub private_browsing_detection: Option<bool>,
    #[serde(default)]
    pub setup_completed: Option<bool>,
    #[serde(default)]
    pub clipboard_enabled: Option<bool>,
    #[serde(default)]
    pub screen_capture_enabled: Option<bool>,
    #[serde(default)]
    pub remote_access_enabled: Option<bool>,
    /// Anthropic model used for fast/routine tasks (e.g. classification, tagging).
    #[serde(default)]
    pub routine_model: Option<String>,
    /// Anthropic model used for synthesis tasks (e.g. distillation, narrative).
    #[serde(default)]
    pub synthesis_model: Option<String>,
    /// Base URL for an OpenAI-compatible external LLM endpoint.
    #[serde(default)]
    pub external_llm_endpoint: Option<String>,
    /// Model identifier to use with the external LLM endpoint.
    #[serde(default)]
    pub external_llm_model: Option<String>,
}

// ===== Chunks / indexed files =====

#[derive(Debug, Serialize, Deserialize)]
pub struct DeleteByTimeRangeRequest {
    pub start: i64,
    pub end: i64,
}

#[derive(Debug, Serialize, Deserialize)]
pub struct BulkDeleteItem {
    pub source: String,
    pub source_id: String,
}

#[derive(Debug, Serialize, Deserialize)]
pub struct BulkDeleteRequest {
    pub items: Vec<BulkDeleteItem>,
}

#[derive(Debug, Serialize, Deserialize)]
pub struct UpdateChunkRequest {
    pub content: String,
}

// ===== Entity / Observation CRUD =====

#[derive(Debug, Serialize, Deserialize)]
pub struct ConfirmEntityRequest {
    #[serde(default = "default_confirmed")]
    pub confirmed: bool,
}

#[derive(Debug, Serialize, Deserialize)]
pub struct AddEntityObservationRequest {
    pub content: String,
    #[serde(default)]
    pub source_agent: Option<String>,
    #[serde(default)]
    pub confidence: Option<f32>,
}

#[derive(Debug, Serialize, Deserialize)]
pub struct UpdateObservationRequest {
    pub content: String,
}

#[derive(Debug, Serialize, Deserialize)]
pub struct ConfirmObservationRequest {
    #[serde(default = "default_confirmed")]
    pub confirmed: bool,
}

// ===== Spaces extended =====

#[derive(Debug, Serialize, Deserialize)]
pub struct ReorderSpaceRequest {
    pub name: String,
    pub new_order: i64,
}

#[derive(Debug, Serialize, Deserialize)]
pub struct SetDocumentSpaceRequest {
    pub space_name: String,
}

// ===== Tags =====

#[derive(Debug, Serialize, Deserialize)]
pub struct SetDocumentTagsRequest {
    pub tags: Vec<String>,
}

// ===== Memory update =====

#[derive(Debug, Serialize, Deserialize)]
pub struct UpdateMemoryRequest {
    #[serde(default)]
    pub content: Option<String>,
    #[serde(default, alias = "domain")]
    pub space: Option<String>,
    #[serde(default)]
    pub confirmed: Option<bool>,
    #[serde(default)]
    pub memory_type: Option<String>,
}

#[derive(Debug, Serialize, Deserialize)]
pub struct SetStabilityRequest {
    pub stability: String,
}

#[derive(Debug, Serialize, Deserialize)]
pub struct CorrectMemoryRequest {
    pub correction_prompt: String,
}

// ===== Concepts update =====

#[derive(Debug, Serialize, Deserialize)]
pub struct UpdatePageRequest {
    pub content: String,
    /// Source memory IDs to associate with this page version.
    /// Omitted or empty by HTTP callers that preserve existing sources;
    /// always populated by `post_write::update_page`.
    #[serde(default)]
    pub source_memory_ids: Vec<String>,
}

/// Body for `PUT /api/pages/{id}` — agent-side refresh of a stale page.
///
/// Distinct from `UpdatePageRequest` (manual content edit via POST) because:
///  - `source_memory_ids` is replaced, not preserved.
///  - `summary` is optionally updated.
///  - The handler clears `stale_reason` in the same transaction.
///
/// v1 deliberately excludes title / entity_id / space changes — slug rename
/// has its own concurrent-read failure mode and lands as a separate route.
#[derive(Debug, Serialize, Deserialize)]
pub struct RefreshPageRequest {
    pub content: String,
    pub source_memory_ids: Vec<String>,
    #[serde(default)]
    pub summary: Option<String>,
}

// ===== Concept Export =====

/// Request body for `POST /api/pages/export` (bulk export all pages to an Obsidian vault).
#[derive(Debug, Deserialize, Serialize)]
pub struct ExportPagesRequest {
    pub vault_path: Option<String>,
}

#[derive(Debug, Deserialize, Serialize)]
pub struct ExportPageRequest {
    pub vault_path: String,
}

// ===== LLM test =====

/// `POST /api/llm/test` — probe an OpenAI-compatible LLM endpoint with a 1-shot prompt.
/// Used by the app settings UI to validate a custom endpoint before saving.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TestLlmRequest {
    pub endpoint: String,
    pub model: String,
    /// Optional override prompt. Defaults to "Say 'hello' and nothing else." server-side.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub prompt: Option<String>,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TestLlmResponse {
    pub response: String,
}

// ===== Default value functions =====

fn default_limit() -> usize {
    10
}

fn default_list_limit() -> usize {
    100
}

fn default_confirmed() -> bool {
    true
}

fn default_max_chunks() -> usize {
    5
}

fn default_true() -> bool {
    true
}

fn default_entity_search_limit() -> usize {
    20
}

#[cfg(test)]
mod search_pages_page_type_test {
    use super::*;

    #[test]
    fn search_pages_request_accepts_page_type() {
        let json = r#"{"query":"foo","limit":10,"page_type":"recap"}"#;
        let parsed: SearchPagesRequest = serde_json::from_str(json).unwrap();
        assert_eq!(parsed.page_type.as_deref(), Some("recap"));
    }

    #[test]
    fn search_pages_request_page_type_optional() {
        let json = r#"{"query":"foo","limit":10}"#;
        let parsed: SearchPagesRequest = serde_json::from_str(json).unwrap();
        assert!(parsed.page_type.is_none());
    }
}

#[cfg(test)]
mod list_memories_confirmed_test {
    use super::*;

    #[test]
    fn confirmed_false_serializes_to_json() {
        let req = ListMemoriesRequest {
            memory_type: None,
            space: None,
            limit: 20,
            confirmed: Some(false),
        };
        let s = serde_json::to_string(&req).unwrap();
        assert!(s.contains("\"confirmed\":false"), "got: {s}");
    }

    #[test]
    fn confirmed_none_skips_serialization() {
        let req = ListMemoriesRequest {
            memory_type: None,
            space: None,
            limit: 20,
            confirmed: None,
        };
        let s = serde_json::to_string(&req).unwrap();
        assert!(!s.contains("confirmed"), "got: {s}");
    }
}
