//! Shared request/response DTOs and result-mapping for the data routes.
//!
//! The standalone router ([`crate::router`]) and the cluster router
//! ([`crate::cluster`]) expose the SAME data surface (`/items`,
//! `/embeddings`, `/signals`, `/feed`, `/search`). Their request/response
//! shapes and the engine-result → JSON mapping were previously duplicated
//! verbatim across both modules; any drift between the two (e.g. a new field
//! added on one side only) would silently change one mode's API. This module
//! is the single source of truth so the two routers cannot diverge.

use std::collections::HashMap;

use serde::{Deserialize, Serialize};
use tidaldb::query::{retrieve::RetrieveResult, search::SearchResultItem};
use utoipa::ToSchema;

// ── Request DTOs ─────────────────────────────────────────────────────────────

/// `POST /items` body.
#[derive(Debug, Deserialize, ToSchema)]
pub struct ItemRequest {
    /// Entity ID of the item to create.
    #[schema(example = 1)]
    pub entity_id: u64,
    /// Arbitrary string→string metadata (e.g. `title`, `category`, `created_at`).
    pub metadata: HashMap<String, String>,
}

/// `POST /embeddings` body.
#[derive(Debug, Deserialize, ToSchema)]
pub struct EmbeddingRequest {
    /// Entity ID the embedding belongs to.
    #[schema(example = 1)]
    pub entity_id: u64,
    /// Dense embedding vector. The database retrieves and ranks over these
    /// vectors; it does not generate them.
    pub values: Vec<f32>,
}

/// `POST /signals` body.
///
/// `user_id` / `creator_id` are optional signal context. They are read on the
/// standalone path; the cluster path ignores them (its replication layer does
/// not yet carry per-signal context), so defaulting them keeps both modes'
/// wire contract identical.
#[derive(Debug, Deserialize, ToSchema)]
pub struct SignalRequest {
    /// Entity the signal is recorded against.
    #[schema(example = 1)]
    pub entity_id: u64,
    /// Signal type name (e.g. `view`, `like`, `share`).
    #[schema(example = "view")]
    pub signal: String,
    /// Signal weight applied to the running decay score.
    #[schema(example = 1.0)]
    pub weight: f64,
    /// Optional originating user context (standalone path only).
    #[serde(default)]
    pub user_id: Option<u64>,
    /// Optional originating creator context (standalone path only).
    #[serde(default)]
    pub creator_id: Option<u64>,
}

/// Maximum page size a client may request via `?limit=`.
///
/// `limit` arrives across the network trust boundary and feeds the engine's
/// candidate cap directly. Left unbounded, a single `?limit=4000000000` request
/// would drive the retrieval/scoring pipeline to size buffers for a four-billion
/// row result, a trivial memory-amplification `DoS`. The page-ranking surfaces
/// (`/feed`, `/search`) are interactive UIs — a few hundred ranked items is the
/// realistic ceiling — so 1000 is a generous-but-finite cap. Bulk export uses a
/// separate, much larger ceiling (`ExportRequest::MAX_EXPORT_LIMIT`); this is
/// deliberately the smaller interactive-read limit. Callers clamp with
/// [`FeedQuery::clamped_limit`] / [`SearchQueryParams::clamped_limit`] at the
/// boundary rather than trusting the raw value.
pub const MAX_LIMIT: u32 = 1000;

/// `GET /feed` query parameters.
#[derive(Debug, Deserialize, ToSchema, utoipa::IntoParams)]
#[into_params(parameter_in = Query)]
pub struct FeedQuery {
    /// Optional user to personalize ranking for.
    #[serde(default)]
    pub user_id: Option<u64>,
    /// Named ranking profile; defaults to `for_you`.
    #[serde(default = "default_profile")]
    #[param(example = "for_you")]
    pub profile: String,
    /// Page size; clamped to `MAX_LIMIT` (1000) at the trust boundary.
    #[serde(default = "default_limit")]
    #[param(example = 20)]
    pub limit: u32,
    /// Target region (cluster mode only; rejected with 400 standalone).
    #[serde(default)]
    pub region: Option<String>,
}

impl FeedQuery {
    /// The requested page size clamped to [`MAX_LIMIT`].
    ///
    /// Always call this instead of reading `limit` directly: it is the single
    /// enforcement point for the network trust boundary, so an oversized client
    /// value can never reach the engine's candidate cap.
    #[must_use]
    pub const fn clamped_limit(&self) -> u32 {
        if self.limit > MAX_LIMIT {
            MAX_LIMIT
        } else {
            self.limit
        }
    }
}

/// `GET /search` query parameters.
#[derive(Debug, Deserialize, ToSchema, utoipa::IntoParams)]
#[into_params(parameter_in = Query)]
pub struct SearchQueryParams {
    /// Free-text query string (BM25 + ANN hybrid).
    #[param(example = "jazz piano")]
    pub query: String,
    /// Optional user to personalize ranking for.
    #[serde(default)]
    pub user_id: Option<u64>,
    /// Page size; clamped to `MAX_LIMIT` (1000) at the trust boundary.
    #[serde(default = "default_limit")]
    #[param(example = 20)]
    pub limit: u32,
    /// Target region (cluster mode only; rejected with 400 standalone).
    #[serde(default)]
    pub region: Option<String>,
}

impl SearchQueryParams {
    /// The requested page size clamped to [`MAX_LIMIT`].
    ///
    /// Always call this instead of reading `limit` directly: it is the single
    /// enforcement point for the network trust boundary, so an oversized client
    /// value can never reach the engine's candidate cap.
    #[must_use]
    pub const fn clamped_limit(&self) -> u32 {
        if self.limit > MAX_LIMIT {
            MAX_LIMIT
        } else {
            self.limit
        }
    }
}

/// Default ranking profile when `?profile=` is omitted.
#[must_use]
pub fn default_profile() -> String {
    "for_you".into()
}

/// Default page size when `?limit=` is omitted.
#[must_use]
pub const fn default_limit() -> u32 {
    20
}

// ── Response DTOs ────────────────────────────────────────────────────────────

/// `GET /feed` response body.
#[derive(Debug, Serialize, ToSchema)]
pub struct FeedResponse {
    /// Ranked items, best-first.
    pub items: Vec<FeedItem>,
    /// Total candidates considered before ranking/diversity.
    pub total_candidates: usize,
    /// Region the feed was served from (cluster mode); `null` standalone.
    pub region: Option<String>,
}

/// One ranked item in a feed response.
#[derive(Debug, Serialize, ToSchema)]
pub struct FeedItem {
    /// Entity ID of the ranked item.
    pub entity_id: u64,
    /// Final ranking score.
    pub score: f64,
    /// 1-based rank within the page.
    pub rank: usize,
    /// Signal snapshot for explainability; omitted when empty.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub signals: Option<Vec<SignalValue>>,
}

/// A signal value snapshot attached to a ranked item, for explainability.
#[derive(Debug, Serialize, ToSchema)]
pub struct SignalValue {
    /// Signal type name.
    pub name: String,
    /// Decayed signal value at query time.
    pub value: f64,
}

/// `GET /search` response body.
#[derive(Debug, Serialize, ToSchema)]
pub struct SearchResponse {
    /// Ranked search results, best-first.
    pub items: Vec<SearchItem>,
    /// Total candidates considered before ranking/diversity.
    pub total_candidates: usize,
    /// Region the search was served from (cluster mode); `null` standalone.
    pub region: Option<String>,
}

/// One ranked item in a search response.
#[derive(Debug, Serialize, ToSchema)]
pub struct SearchItem {
    /// Entity ID of the ranked item.
    pub entity_id: u64,
    /// Final fused ranking score.
    pub score: f64,
    /// 1-based rank within the page.
    pub rank: usize,
    /// BM25 lexical component; omitted when the query had no text match path.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub bm25_score: Option<f64>,
    /// Semantic (ANN) component; omitted when no vector match path ran.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub semantic_score: Option<f64>,
}

// ── Engine-result → DTO mapping ──────────────────────────────────────────────

/// Map one engine [`RetrieveResult`] into a wire [`FeedItem`].
///
/// An empty signal list serializes as absent (`None`) rather than `[]` so the
/// response stays compact.
#[must_use]
pub fn feed_item(item: &RetrieveResult) -> FeedItem {
    let signals = if item.signals.is_empty() {
        None
    } else {
        Some(
            item.signals
                .iter()
                .map(|s| SignalValue {
                    name: s.name.clone(),
                    value: s.value,
                })
                .collect(),
        )
    };
    FeedItem {
        entity_id: item.entity_id.as_u64(),
        score: item.score,
        rank: item.rank,
        signals,
    }
}

/// Map a slice of engine [`RetrieveResult`]s into wire [`FeedItem`]s.
#[must_use]
pub fn feed_items(items: &[RetrieveResult]) -> Vec<FeedItem> {
    items.iter().map(feed_item).collect()
}

/// Map one engine [`SearchResultItem`] into a wire [`SearchItem`].
#[must_use]
pub fn search_item(item: &SearchResultItem) -> SearchItem {
    SearchItem {
        entity_id: item.entity_id.as_u64(),
        score: item.score,
        rank: item.rank,
        bm25_score: item.bm25_score.map(f64::from),
        semantic_score: item.semantic_score.map(f64::from),
    }
}

/// Map a slice of engine [`SearchResultItem`]s into wire [`SearchItem`]s.
#[must_use]
pub fn search_items(items: &[SearchResultItem]) -> Vec<SearchItem> {
    items.iter().map(search_item).collect()
}

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

    fn feed_query(limit: u32) -> FeedQuery {
        FeedQuery {
            user_id: None,
            profile: default_profile(),
            limit,
            region: None,
        }
    }

    fn search_query(limit: u32) -> SearchQueryParams {
        SearchQueryParams {
            query: "q".into(),
            user_id: None,
            limit,
            region: None,
        }
    }

    #[test]
    fn feed_limit_clamped_at_boundary() {
        // An oversized client-supplied limit is capped, never passed through.
        assert_eq!(feed_query(u32::MAX).clamped_limit(), MAX_LIMIT);
        assert_eq!(feed_query(MAX_LIMIT + 1).clamped_limit(), MAX_LIMIT);
        // Values at or below the cap pass through unchanged.
        assert_eq!(feed_query(MAX_LIMIT).clamped_limit(), MAX_LIMIT);
        assert_eq!(feed_query(default_limit()).clamped_limit(), default_limit());
        assert_eq!(feed_query(0).clamped_limit(), 0);
    }

    #[test]
    fn search_limit_clamped_at_boundary() {
        assert_eq!(search_query(u32::MAX).clamped_limit(), MAX_LIMIT);
        assert_eq!(search_query(MAX_LIMIT + 1).clamped_limit(), MAX_LIMIT);
        assert_eq!(search_query(MAX_LIMIT).clamped_limit(), MAX_LIMIT);
        assert_eq!(
            search_query(default_limit()).clamped_limit(),
            default_limit()
        );
    }
}
