//! Admin full-text search over catalogue objects. Read capability: `ViewInternal`
//! (admins search across all visibility levels). Backed by the Meilisearch index.

use auth::{Authorized, ViewInternal};
use axum::{
    Json, Router,
    extract::{Query, State},
    http::StatusCode,
    routing::get,
};
use serde::{Deserialize, Serialize};
use utoipa::ToSchema;

use crate::AppState;

#[derive(Deserialize)]
pub(crate) struct SearchParams {
    #[serde(default)]
    q: String,
    visibility: Option<String>,
    offset: Option<i64>,
    limit: Option<i64>,
}

#[derive(Serialize, ToSchema)]
pub(crate) struct SearchHitView {
    pub id: String,
    pub object_number: String,
    pub object_name: String,
    pub brief_description: Option<String>,
    #[schema(value_type = domain::Visibility)]
    pub visibility: String,
    pub recording_date: Option<String>,
    pub snippet: Option<String>,
}

#[derive(Serialize, ToSchema)]
pub(crate) struct SearchResultsView {
    pub hits: Vec<SearchHitView>,
    /// Meilisearch's estimate of the total number of matches.
    pub estimated_total: usize,
}

#[utoipa::path(
    get, path = "/api/admin/search",
    params(
        ("q" = String, Query, description = "Search query text"),
        ("visibility" = Option<String>, Query, description = "Filter: draft|internal|public"),
        ("offset" = Option<i64>, Query, description = "default 0"),
        ("limit" = Option<i64>, Query, description = "1..=50, default 20")
    ),
    responses(
        (status = 200, body = SearchResultsView),
        (status = 400, description = "Invalid visibility value"),
        (status = 401),
        (status = 403),
        (status = 503, description = "Search is not configured")
    )
)]
pub(crate) async fn search_objects(
    _auth: Authorized<ViewInternal>,
    State(state): State<AppState>,
    Query(params): Query<SearchParams>,
) -> Result<Json<SearchResultsView>, StatusCode> {
    let Some(search) = &state.search else {
        return Err(StatusCode::SERVICE_UNAVAILABLE);
    };

    let visibility = match params.visibility.as_deref() {
        None | Some("") => None,
        Some(v @ ("draft" | "internal" | "public")) => Some(v),
        Some(_) => return Err(StatusCode::BAD_REQUEST),
    };

    let q = params.q.trim();

    if q.is_empty() {
        return Ok(Json(SearchResultsView {
            hits: Vec::new(),
            estimated_total: 0,
        }));
    }

    // Search uses a tighter default/cap (20, max 50) than the shared `Pagination`
    // (default 50, max 200): result pages are slower to scan than a raw object list.
    let offset = params.offset.unwrap_or(0).max(0) as usize;
    let limit = params.limit.unwrap_or(20).clamp(1, 50) as usize;

    let results = search
        .search_objects(q, visibility, offset, limit)
        .await
        .map_err(|err| {
            tracing::error!(?err, "search query failed");
            StatusCode::INTERNAL_SERVER_ERROR
        })?;

    Ok(Json(SearchResultsView {
        hits: results
            .hits
            .into_iter()
            .map(|h| SearchHitView {
                id: h.id,
                object_number: h.object_number,
                object_name: h.object_name,
                brief_description: h.brief_description,
                visibility: h.visibility,
                recording_date: h.recording_date,
                snippet: h.snippet,
            })
            .collect(),
        estimated_total: results.estimated_total,
    }))
}

pub(crate) fn routes() -> Router<AppState> {
    Router::new().route("/api/admin/search", get(search_objects))
}
