//! axum handlers for `cqs serve`. Each one emits a `tracing::info!`
//! event on entry so request flows show up in the journal.
//!
//! All handlers wrap sync `Store` calls in `tokio::task::spawn_blocking`
//! to avoid the "runtime within a runtime" panic that would otherwise
//! fire when the Store's internal `block_on` is invoked from axum's
//! async context. Heavy SQL queries live in `super::data::build_*`.

use crate::store::{ReadOnly, Store};
use axum::{
    extract::{Path, Query, State},
    http::StatusCode,
    Json,
};
use serde::Deserialize;

use super::data::{
    ChunkDetail, ClusterResponse, EvalGoldResponse, GraphResponse, HierarchyDirection,
    HierarchyResponse, NodeRef, SearchResponse, StatsResponse,
};
use super::error::ServeError;
use super::AppState;

/// Shared scaffolding for every async handler that runs a sync `Store` call
/// inside `spawn_blocking`. Centralizes the
/// `Span::current()` capture, `state.blocking_permits` acquire (with the
/// canonical `"blocking permit: {e}"` error string), `spawn_blocking`
/// dispatch, span re-entry inside the closure, permit-hold-via-move,
/// `await`, and join-error mapping. Each handler shrinks from ~30 LOC
/// of scaffolding to one `with_blocking(...)` call plus its actual
/// per-handler arg shaping.
///
/// `label` is used in the join-error string only (`"{label} join: ..."`).
/// Labels are `"stats join"` / `"graph join"` / etc. — kept stable so log
/// greps match.
///
/// The closure receives `&Store<ReadOnly>` and returns
/// `Result<T, E: Into<ServeError>>` so callers can pass either
/// `build_*` functions (which return `Result<_, StoreError>`) or
/// direct `store.search_by_name(...)` calls without per-call-site error
/// conversions.
async fn with_blocking<T, E, F>(
    state: &AppState,
    label: &'static str,
    f: F,
) -> Result<T, ServeError>
where
    F: FnOnce(&Store<ReadOnly>) -> Result<T, E> + Send + 'static,
    T: Send + 'static,
    E: Into<ServeError> + Send + 'static,
{
    // Capture the per-request span (TraceLayer assigns one) and re-enter it
    // inside the blocking closure so the inner `build_*`
    // span lands as a child of the http_request span. Without this,
    // `RUST_LOG=info` shows `build_*` orphaned from the request and
    // operators can't correlate slow handler latency.
    let span = tracing::Span::current();
    let store = state.store.clone();

    // Acquire a semaphore permit before queueing the blocking job. Caps
    // concurrent SQL-bound work at `serve_blocking_permits()`
    // so a fan-out client can't pin the full 512-thread axum default
    // blocking pool with idle SQLite handles. Permit is held for the
    // life of the spawned closure via `acquire_owned()` + closure move.
    let permit = state
        .blocking_permits
        .clone()
        .acquire_owned()
        .await
        .map_err(|e| ServeError::Internal(format!("blocking permit: {e}")))?;

    // Store's sync API uses its own internal `block_on`. Wrap in
    // `spawn_blocking` to avoid the "runtime within a runtime" panic
    // when called from axum's async context.
    tokio::task::spawn_blocking(move || {
        let _permit = permit;
        let _entered = span.enter();
        f(&store)
    })
    .await
    .map_err(|e| ServeError::Internal(format!("{label} join: {e}")))?
    .map_err(Into::into)
}

// ─── token-leak posture ─────────────────────────────────────────────
//
// The `Query<T>` extractors below participate in a defense-in-depth
// chain that prevents `?token=…` URLs from leaking into either response
// bodies or trace logs:
//   1. **Body**: axum 0.8's `Query<T>` rejection emits
//      `"Failed to deserialize query string: <field>: <serde error>"` —
//      the URI itself is never placed in the body. Confirmed against
//      axum-core 0.5 / serde_path_to_error 0.1.
//   2. **Trace span**: the TraceLayer `make_span_with` records
//      `path = %req.uri().path()` only — query string excluded from spans.
//   3. **Auth redirect**: when auth is on, `needs_url_strip` (auth.rs)
//      302-redirects any `?token=…` URL before the extractor fires.
//
// tower-http's debug log path does not echo the URI under our config
// (verified against tower-http 0.6 source). Regression-guarded by
// `serve::tests::auth_tests::sec_v136_6_*`.
//
// Adding a new `Query<T>` extractor? You inherit this posture for
// free — no per-handler scrub needed. Don't add fields whose `Display`
// echoes raw input back into trace events; if you must, route them
// through `auth::strip_token_param` first.

#[derive(Debug, Deserialize)]
pub(crate) struct GraphQuery {
    /// Optional file-path filter — extensibility seam for the future
    /// file/module view (`/api/graph?file=src/store/`).
    #[serde(default)]
    pub file: Option<String>,
    /// Optional chunk-type filter (`/api/graph?type=function`).
    #[serde(default)]
    #[serde(rename = "type")]
    pub kind: Option<String>,
    /// Optional cap on returned nodes — defensive default for huge
    /// corpora. Spec mentions `?max_nodes=N` as a fallback if 16k
    /// turns out to be too slow even with WebGL.
    #[serde(default)]
    pub max_nodes: Option<usize>,
}

#[derive(Debug, Deserialize)]
pub(crate) struct SearchQuery {
    pub q: String,
    #[serde(default = "default_search_limit")]
    pub limit: usize,
}

fn default_search_limit() -> usize {
    20
}

#[derive(Debug, Deserialize)]
pub(crate) struct SearchLegsQuery {
    /// The search query.
    pub q: String,
    /// Number of results (`k`). Defaults to `default_legs_k`, clamped 1..=200.
    #[serde(default = "default_legs_k")]
    pub k: usize,
    /// Optional constant SPLADE fusion weight α (overrides the per-category
    /// router). 1.0 = pure cosine, 0.0 = pure sparse.
    #[serde(default)]
    pub splade_alpha: Option<f32>,
}

fn default_legs_k() -> usize {
    10
}

#[derive(Debug, Deserialize)]
pub(crate) struct HierarchyQuery {
    /// `callers` (BFS up) or `callees` (BFS down). Defaults to `callees`.
    #[serde(default)]
    pub direction: Option<String>,
    /// BFS depth from root. Defaults to 5, clamped to 1..=10.
    #[serde(default)]
    pub depth: Option<u32>,
}

const DEFAULT_HIERARCHY_DEPTH: u32 = 5;
const MAX_HIERARCHY_DEPTH: u32 = 10;

#[derive(Debug, Deserialize)]
pub(crate) struct ClusterQuery {
    /// Optional cap on returned nodes — same convention as `/api/graph`.
    #[serde(default)]
    pub max_nodes: Option<usize>,
}

/// `GET /health` — always returns 200. Used by orchestration / monitoring.
pub(crate) async fn health() -> (StatusCode, &'static str) {
    (StatusCode::OK, "ok")
}

/// `GET /api/stats` — small payload for the header bar.
pub(crate) async fn stats(
    State(state): State<AppState>,
) -> Result<Json<StatsResponse>, ServeError> {
    tracing::info!("serve::stats");
    let stats = with_blocking(&state, "stats", super::data::build_stats).await?;
    Ok(Json(stats))
}

/// `GET /api/graph` — full graph (all chunks + call edges), or filtered
/// subset per query params.
pub(crate) async fn graph(
    State(state): State<AppState>,
    Query(params): Query<GraphQuery>,
) -> Result<Json<GraphResponse>, ServeError> {
    tracing::info!(
        file = ?params.file,
        kind = ?params.kind,
        max_nodes = ?params.max_nodes,
        "serve::graph"
    );

    let GraphQuery {
        file,
        kind,
        max_nodes,
    } = params;
    let graph = with_blocking(&state, "graph", move |store| {
        super::data::build_graph(store, file.as_deref(), kind.as_deref(), max_nodes)
    })
    .await?;
    Ok(Json(graph))
}

/// `GET /api/chunk/:id` — sidebar payload for one chunk.
pub(crate) async fn chunk_detail(
    State(state): State<AppState>,
    Path(id): Path<String>,
) -> Result<Json<ChunkDetail>, ServeError> {
    // Cap user-controlled path string at 256 chars before logging at info to
    // limit journald inflation. The full id is still passed downstream for
    // the lookup; this only bounds the log event field.
    tracing::info!(
        chunk_id = %id.chars().take(256).collect::<String>(),
        id_len = id.len(),
        "serve::chunk_detail"
    );

    let id_clone = id.clone();
    let detail = with_blocking(&state, "chunk_detail", move |store| {
        super::data::build_chunk_detail(store, &id_clone)
    })
    .await?;

    detail
        .map(Json)
        .ok_or_else(|| ServeError::NotFound(format!("chunk: {id}")))
}

/// `GET /api/search?q=foo` — name-based search via FTS5 prefix match.
///
/// Already wired — `Store::search_by_name` is a fast existing path.
/// Highlights matching nodes in the UI.
pub(crate) async fn search(
    State(state): State<AppState>,
    Query(params): Query<SearchQuery>,
) -> Result<Json<SearchResponse>, ServeError> {
    // Log only metadata at info; full query at debug so it's available for
    // local debugging but not journal-retained by default. The TraceLayer
    // span already has the redacted URI; emitting `query = <full text>` at
    // info would bypass that redaction and persist a credential pasted as a
    // search query straight into the journal.
    tracing::debug!(query = %params.q, "serve::search query received");
    tracing::info!(
        q_len = params.q.len(),
        limit = params.limit,
        "serve::search"
    );

    if params.q.trim().is_empty() {
        return Ok(Json(SearchResponse {
            matches: Vec::new(),
        }));
    }

    let q = params.q.clone();
    let limit = params.limit.clamp(1, 200);
    let results = with_blocking(&state, "search", move |store| {
        store.search_by_name(&q, limit)
    })
    .await?;

    let matches: Vec<NodeRef> = results
        .into_iter()
        .map(|r| NodeRef {
            id: r.chunk.id.clone(),
            name: r.chunk.name.clone(),
            file: r.chunk.file.display().to_string(),
            line_start: r.chunk.line_start,
        })
        .collect();

    tracing::info!(matches = matches.len(), "search returned");
    Ok(Json(SearchResponse { matches }))
}

/// `GET /api/search_legs?q=…&k=…[&splade_alpha=…]` — the SPLADE-fusion
/// inspector ("mechanism mode").
///
/// Unlike the other `/api/*` handlers, this one holds NO retrieval stack in the
/// serve process: the embedder / vector index / SPLADE index live only in the
/// retrieval daemon (`cqs watch --serve`). This handler is a CLIENT of that
/// daemon's socket — it forwards the query as a `search-legs` verb and returns
/// the daemon's three pre-fusion legs (dense / sparse / fused) verbatim.
///
/// If the daemon is down (or no socket path was resolved), it returns a clean
/// 503 with the "mechanism mode requires `cqs watch --serve`" hint — it never
/// silently falls back to FTS.
pub(crate) async fn search_legs(
    State(state): State<AppState>,
    Query(params): Query<SearchLegsQuery>,
) -> Result<Json<serde_json::Value>, ServeError> {
    tracing::debug!(query = %params.q, "serve::search_legs query received");
    tracing::info!(q_len = params.q.len(), k = params.k, "serve::search_legs");

    if params.q.trim().is_empty() {
        return Err(ServeError::BadRequest(
            "missing query parameter 'q'".to_string(),
        ));
    }

    let socket = match &state.daemon_socket {
        Some(s) => s.clone(),
        None => {
            // No socket path was resolved at launch — mechanism mode is
            // structurally unavailable. Same 503 the daemon-down path returns.
            return Err(ServeError::ServiceUnavailable(
                "mechanism mode requires the retrieval daemon — start it with `cqs watch --serve`"
                    .to_string(),
            ));
        }
    };

    search_legs_dispatch(state, socket, params).await
}

/// Build the daemon argv for a `search-legs` forward. The flags go first, then
/// a `--` end-of-options terminator, then the client query as the trailing
/// positional — so a query supplied by the untrusted web client that begins
/// with `-` / `--` is parsed by the daemon-side clap as the positional query,
/// never as a daemon flag. Without the terminator a query like
/// `--limit 9999` or `--path /etc` would inject flags into the forwarded
/// command.
fn build_search_legs_daemon_args(q: &str, k: usize, splade_alpha: Option<f32>) -> Vec<String> {
    let mut args = vec!["--limit".to_string(), k.to_string()];
    if let Some(alpha) = splade_alpha {
        args.push("--splade-alpha".to_string());
        args.push(alpha.to_string());
    }
    // End-of-options: everything after this is positional, so the query can
    // never be read as a flag regardless of its leading characters.
    args.push("--".to_string());
    args.push(q.to_string());
    args
}

/// The daemon round-trip half of [`search_legs`], split out so the unix-only
/// daemon-client path can be cfg-gated independently of the request validation
/// above. The daemon client speaks a Unix domain socket (`std::os::unix::net`),
/// so on non-unix this whole path compiles out and returns the same 503 the
/// daemon-down path returns — there is no daemon to reach until the named-pipe
/// Windows transport exists.
#[cfg(unix)]
async fn search_legs_dispatch(
    state: AppState,
    socket: std::sync::Arc<std::path::PathBuf>,
    params: SearchLegsQuery,
) -> Result<Json<serde_json::Value>, ServeError> {
    // Build the daemon argv: the verb is supplied by the client; these are the
    // tokens after it. Clamp `k` to the same bound the name-search handler uses.
    let k = params.k.clamp(1, 200);
    let args = build_search_legs_daemon_args(&params.q, k, params.splade_alpha);

    // The daemon round-trip is blocking socket I/O; run it off the async
    // executor. The permit cap shares the same semaphore the SQL handlers use
    // so a fan-out client can't pin unbounded blocking threads.
    let permit = state
        .blocking_permits
        .clone()
        .acquire_owned()
        .await
        .map_err(|e| ServeError::Internal(format!("blocking permit: {e}")))?;
    let span = tracing::Span::current();
    let output = tokio::task::spawn_blocking(move || {
        let _permit = permit;
        let _entered = span.enter();
        super::daemon_client::query_search_legs(&socket, &args)
    })
    .await
    .map_err(|e| ServeError::Internal(format!("search_legs join: {e}")))??;

    tracing::info!("serve::search_legs forwarded to daemon");
    Ok(Json(output))
}

/// Non-unix stand-in for [`search_legs_dispatch`]: the retrieval daemon is
/// reached over a Unix domain socket, which does not exist on this platform, so
/// mechanism mode is structurally unavailable and the endpoint returns a clean
/// 503 rather than failing to compile.
#[cfg(not(unix))]
async fn search_legs_dispatch(
    _state: AppState,
    _socket: std::sync::Arc<std::path::PathBuf>,
    _params: SearchLegsQuery,
) -> Result<Json<serde_json::Value>, ServeError> {
    Err(ServeError::ServiceUnavailable(
        "mechanism mode requires the retrieval daemon over a unix socket; not available on this platform"
            .to_string(),
    ))
}

/// `GET /api/eval_gold` — the eval query set that drives the Stage-2b
/// "where hybrid wins" tour. Returns each eval query with its category, split,
/// and gold `(origin, name)` resolved to current chunk ids against the served
/// index, so the frontend can fetch `/api/search_legs` per query and read the
/// honest per-query R@K delta (gold's fused-leg rank vs dense-alone rank).
///
/// Reads the fixtures from the project root passed at launch (`state.eval_root`).
/// Returns a clean 503 when no root was resolved (the route is structurally
/// unavailable) or when the fixtures are absent at that root — the tour degrades
/// to "no eval set here", never a fake empty success.
pub(crate) async fn eval_gold(
    State(state): State<AppState>,
) -> Result<Json<EvalGoldResponse>, ServeError> {
    tracing::info!("serve::eval_gold");

    let root = match &state.eval_root {
        Some(r) => r.clone(),
        None => {
            return Err(ServeError::ServiceUnavailable(
                "eval-gold tour unavailable: cqs serve was not launched from a project root \
                 (no path resolved for evals/queries/)"
                    .to_string(),
            ));
        }
    };

    let response = with_blocking(&state, "eval_gold", move |store| {
        super::data::build_eval_gold(store, root.as_ref())
    })
    .await?;

    Ok(Json(response))
}

/// `GET /api/hierarchy/{id}?direction={callers|callees}&depth=N`
///
/// BFS subgraph from a chunk. Returns nodes annotated with `bfs_depth`
/// so the frontend can lock the Z axis to depth and render a tree-shaped
/// 3D layout. Depth is clamped to 1..=10 to bound response size on
/// densely-connected codebases.
pub(crate) async fn hierarchy(
    State(state): State<AppState>,
    Path(id): Path<String>,
    Query(params): Query<HierarchyQuery>,
) -> Result<Json<HierarchyResponse>, ServeError> {
    let direction_str = params.direction.as_deref().unwrap_or("callees").to_string();
    let direction = HierarchyDirection::parse(&direction_str).ok_or_else(|| {
        ServeError::BadRequest(format!(
            "direction must be 'callers' or 'callees', got '{direction_str}'"
        ))
    })?;
    let depth = params
        .depth
        .unwrap_or(DEFAULT_HIERARCHY_DEPTH)
        .clamp(1, MAX_HIERARCHY_DEPTH);

    tracing::info!(
        chunk_id = %id,
        direction = direction.as_str(),
        depth,
        "serve::hierarchy"
    );

    let id_clone = id.clone();
    let response = with_blocking(&state, "hierarchy", move |store| {
        super::data::build_hierarchy(store, &id_clone, direction, depth)
    })
    .await?;

    response
        .map(Json)
        .ok_or_else(|| ServeError::NotFound(format!("chunk: {id}")))
}

/// `GET /api/embed/2d?max_nodes=N` — every chunk that has a UMAP projection
/// stored in `umap_x` / `umap_y`, with degree counts attached. The cluster
/// view consumes this directly. Returns an empty `nodes` list (and
/// `skipped > 0`) when the corpus has chunks but no projection has been
/// computed yet — frontend renders a "run `cqs index --umap`" hint.
pub(crate) async fn cluster_2d(
    State(state): State<AppState>,
    Query(params): Query<ClusterQuery>,
) -> Result<Json<ClusterResponse>, ServeError> {
    tracing::info!(max_nodes = ?params.max_nodes, "serve::cluster_2d");

    let max_nodes = params.max_nodes;
    let cluster = with_blocking(&state, "cluster", move |store| {
        super::data::build_cluster(store, max_nodes)
    })
    .await?;

    Ok(Json(cluster))
}
