use std::path::PathBuf;
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::Arc;
use std::time::{Instant, SystemTime, UNIX_EPOCH};

use axum::extract::{Path, Query, State};
use axum::http::StatusCode;
use axum::response::IntoResponse;
use axum::routing::{delete, get, post};
use axum::Json;
use serde::{Deserialize, Serialize};
use tracing::{error, warn};

use crate::actors::{ActorRegistry, ActorSessionView, RegisterRequest};
use crate::cache::NclCache;
use crate::notifications::{AckRequest, NotificationStore, NotificationView};

/// Shared application state injected into handlers.
#[derive(Clone)]
pub struct AppState {
    pub cache: Arc<NclCache>,
    pub project_root: PathBuf,
    pub ontoref_root: Option<PathBuf>,
    pub started_at: Instant,
    pub last_activity: Arc<AtomicU64>,
    pub actors: Arc<ActorRegistry>,
    pub notifications: Arc<NotificationStore>,
    /// Resolved NICKEL_IMPORT_PATH for UI-initiated NCL exports.
    pub nickel_import_path: Option<String>,
    #[cfg(feature = "db")]
    pub db: Option<Arc<stratum_db::StratumDb>>,
    #[cfg(feature = "nats")]
    pub nats: Option<Arc<crate::nats::NatsPublisher>>,
    #[cfg(feature = "ui")]
    pub tera: Option<Arc<tokio::sync::RwLock<tera::Tera>>>,
    #[cfg(feature = "ui")]
    pub public_dir: Option<PathBuf>,
    #[cfg(feature = "ui")]
    pub registry: Option<Arc<crate::registry::ProjectRegistry>>,
    #[cfg(feature = "ui")]
    pub sessions: Arc<crate::session::SessionStore>,
    /// Current project set by `set_project` MCP tool — shared across all
    /// connections.
    #[cfg(feature = "mcp")]
    pub mcp_current_project: Arc<std::sync::RwLock<Option<String>>>,
}

impl AppState {
    fn touch_activity(&self) {
        let now = SystemTime::now()
            .duration_since(UNIX_EPOCH)
            .unwrap_or_default()
            .as_secs();
        self.last_activity.store(now, Ordering::Relaxed);
    }

    /// Derive the project name for the default project.
    pub fn default_project_name(&self) -> String {
        self.project_root
            .file_name()
            .and_then(|n| n.to_str())
            .unwrap_or("unknown")
            .to_string()
    }
}

pub fn router(state: AppState) -> axum::Router {
    let app = axum::Router::new()
        // Existing endpoints
        .route("/health", get(health))
        .route("/nickel/export", post(nickel_export))
        .route("/cache/stats", get(cache_stats))
        .route("/cache/invalidate", post(cache_invalidate))
        // Actor endpoints
        .route("/actors/register", post(actor_register))
        .route("/actors/{token}", delete(actor_deregister))
        .route("/actors/{token}/touch", post(actor_touch))
        .route("/actors/{token}/profile", post(actor_update_profile))
        .route("/actors", get(actors_list))
        // Notification endpoints
        .route("/notifications/pending", get(notifications_pending))
        .route("/notifications/ack", post(notifications_ack))
        // Search endpoint
        .route("/search", get(search))
        // Describe endpoints
        .route("/describe/project", get(describe_project))
        .route("/describe/capabilities", get(describe_capabilities))
        .route("/describe/connections", get(describe_connections))
        .route("/describe/actor-init", get(describe_actor_init))
        // Backlog JSON endpoint
        .route("/backlog-json", get(backlog_json))
        // Q&A read endpoint
        .route("/qa-json", get(qa_json));

    // Gate the mutation endpoint behind the ui feature (requires crate::ui).
    #[cfg(feature = "ui")]
    let app = app
        .route("/qa/add", post(crate::ui::handlers::qa_add))
        .route("/qa/delete", post(crate::ui::handlers::qa_delete))
        .route("/qa/update", post(crate::ui::handlers::qa_update));

    let app = app.with_state(state.clone());

    #[cfg(feature = "ui")]
    let app = {
        use axum::response::Redirect;
        use tower_http::services::ServeDir;

        let app = app
            .route("/ui", get(|| async { Redirect::permanent("/ui/") }))
            .nest("/ui/", crate::ui::router(state.clone()));

        if let Some(ref public_dir) = state.public_dir {
            app.nest_service("/public", ServeDir::new(public_dir))
        } else {
            app
        }
    };

    // MCP streamable-HTTP endpoint — stateless per-request factory.
    #[cfg(feature = "mcp")]
    let app = {
        use rmcp::transport::streamable_http_server::{
            session::local::LocalSessionManager, StreamableHttpServerConfig, StreamableHttpService,
        };
        let mcp_state = state.clone();
        let mcp_svc = StreamableHttpService::new(
            move || Ok(crate::mcp::OntoreServer::new(mcp_state.clone())),
            std::sync::Arc::new(LocalSessionManager::default()),
            StreamableHttpServerConfig::default(),
        );
        app.nest_service("/mcp", mcp_svc)
    };

    app
}

// ── Health ──────────────────────────────────────────────────────────────

#[derive(Serialize)]
struct HealthResponse {
    status: &'static str,
    uptime_secs: u64,
    cache_entries: usize,
    cache_hits: u64,
    cache_misses: u64,
    project_root: String,
    active_actors: usize,
    #[serde(skip_serializing_if = "Option::is_none")]
    db_enabled: Option<bool>,
}

async fn health(State(state): State<AppState>) -> Json<HealthResponse> {
    state.touch_activity();
    let db_enabled = {
        #[cfg(feature = "db")]
        {
            Some(state.db.is_some())
        }
        #[cfg(not(feature = "db"))]
        {
            None
        }
    };
    Json(HealthResponse {
        status: "ok",
        uptime_secs: state.started_at.elapsed().as_secs(),
        cache_entries: state.cache.len(),
        cache_hits: state.cache.hit_count(),
        cache_misses: state.cache.miss_count(),
        project_root: state.project_root.display().to_string(),
        active_actors: state.actors.count(),
        db_enabled,
    })
}

// ── Nickel Export ───────────────────────────────────────────────────────

#[derive(Deserialize)]
struct ExportRequest {
    path: String,
    import_path: Option<String>,
}

#[derive(Serialize)]
struct ExportResponse {
    data: serde_json::Value,
    cached: bool,
    elapsed_ms: u64,
}

async fn nickel_export(
    State(state): State<AppState>,
    Json(req): Json<ExportRequest>,
) -> std::result::Result<Json<ExportResponse>, (StatusCode, String)> {
    state.touch_activity();
    let start = Instant::now();

    // Accept absolute paths — daemon is loopback-only; OS permissions are the
    // boundary. Relative paths are still resolved against project_root for
    // backward compatibility.
    let file_path = resolve_any_path(&state.project_root, &req.path)?;

    let inherited_ip = std::env::var("NICKEL_IMPORT_PATH").unwrap_or_default();
    let merged_ip: Option<String> = match req.import_path.as_deref() {
        Some(caller_ip) => {
            if inherited_ip.is_empty() {
                Some(caller_ip.to_string())
            } else {
                Some(format!("{caller_ip}:{inherited_ip}"))
            }
        }
        None => {
            if inherited_ip.is_empty() {
                None
            } else {
                Some(inherited_ip)
            }
        }
    };

    let (data, was_hit) = state
        .cache
        .export(&file_path, merged_ip.as_deref())
        .await
        .map_err(|e| {
            error!(path = %file_path.display(), error = %e, "nickel export failed");
            (StatusCode::INTERNAL_SERVER_ERROR, e.to_string())
        })?;

    Ok(Json(ExportResponse {
        data,
        cached: was_hit,
        elapsed_ms: start.elapsed().as_millis() as u64,
    }))
}

// ── Cache Management ────────────────────────────────────────────────────

#[derive(Serialize)]
struct CacheStatsResponse {
    entries: usize,
    hits: u64,
    misses: u64,
    hit_rate: f64,
}

async fn cache_stats(State(state): State<AppState>) -> Json<CacheStatsResponse> {
    state.touch_activity();
    let hits = state.cache.hit_count();
    let misses = state.cache.miss_count();
    let total = hits + misses;
    Json(CacheStatsResponse {
        entries: state.cache.len(),
        hits,
        misses,
        hit_rate: if total > 0 {
            hits as f64 / total as f64
        } else {
            0.0
        },
    })
}

#[derive(Deserialize)]
struct InvalidateRequest {
    prefix: Option<String>,
    file: Option<String>,
    all: Option<bool>,
}

#[derive(Serialize)]
struct InvalidateResponse {
    invalidated: bool,
    entries_remaining: usize,
}

async fn cache_invalidate(
    State(state): State<AppState>,
    Json(req): Json<InvalidateRequest>,
) -> std::result::Result<Json<InvalidateResponse>, (StatusCode, String)> {
    state.touch_activity();
    if req.all.unwrap_or(false) {
        state.cache.invalidate_all();
    } else if let Some(prefix) = &req.prefix {
        let path = resolve_path(&state.project_root, prefix)?;
        state.cache.invalidate_prefix(&path);
    } else if let Some(file) = &req.file {
        let path = resolve_path(&state.project_root, file)?;
        state.cache.invalidate_file(&path);
    } else {
        return Err((
            StatusCode::BAD_REQUEST,
            "at least one of 'all', 'prefix', or 'file' must be specified".to_string(),
        ));
    }
    Ok(Json(InvalidateResponse {
        invalidated: true,
        entries_remaining: state.cache.len(),
    }))
}

// ── Actor Endpoints ─────────────────────────────────────────────────────

#[derive(Serialize)]
struct RegisterResponse {
    token: String,
    actors_connected: usize,
}

async fn actor_register(
    State(state): State<AppState>,
    Json(req): Json<RegisterRequest>,
) -> (StatusCode, Json<RegisterResponse>) {
    state.touch_activity();
    #[cfg(feature = "nats")]
    let actor_type = req.actor_type.clone();
    #[cfg(feature = "nats")]
    let project = req.project.clone();
    let token = state.actors.register(req);
    let count = state.actors.count();

    #[cfg(feature = "nats")]
    {
        if let Some(ref nats) = state.nats {
            if let Err(e) = nats
                .publish_actor_registered(&token, &actor_type, &project)
                .await
            {
                tracing::warn!(error = %e, "failed to publish actor.registered event");
            }
        }
    }

    (
        StatusCode::CREATED,
        Json(RegisterResponse {
            token,
            actors_connected: count,
        }),
    )
}

async fn actor_deregister(State(state): State<AppState>, Path(token): Path<String>) -> StatusCode {
    state.touch_activity();
    if state.actors.deregister(&token) {
        #[cfg(feature = "nats")]
        {
            if let Some(ref nats) = state.nats {
                if let Err(e) = nats.publish_actor_deregistered(&token, "explicit").await {
                    tracing::warn!(error = %e, "failed to publish actor.deregistered event");
                }
            }
        }
        StatusCode::NO_CONTENT
    } else {
        StatusCode::NOT_FOUND
    }
}

async fn actor_touch(State(state): State<AppState>, Path(token): Path<String>) -> StatusCode {
    state.touch_activity();
    if state.actors.touch(&token) {
        StatusCode::NO_CONTENT
    } else {
        StatusCode::NOT_FOUND
    }
}

#[derive(Deserialize)]
struct ProfileRequest {
    #[serde(default)]
    role: Option<String>,
    #[serde(default)]
    preferences: Option<serde_json::Value>,
}

async fn actor_update_profile(
    State(state): State<AppState>,
    Path(token): Path<String>,
    Json(req): Json<ProfileRequest>,
) -> StatusCode {
    state.touch_activity();
    if state
        .actors
        .update_profile(&token, req.role, req.preferences)
    {
        StatusCode::NO_CONTENT
    } else {
        StatusCode::NOT_FOUND
    }
}

#[derive(Serialize)]
struct ActorsListResponse {
    actors: Vec<ActorEntry>,
    total: usize,
}

#[derive(Serialize)]
struct ActorEntry {
    token: String,
    #[serde(flatten)]
    session: ActorSessionView,
}

#[derive(Deserialize)]
struct ActorsQuery {
    project: Option<String>,
}

async fn actors_list(
    State(state): State<AppState>,
    Query(query): Query<ActorsQuery>,
) -> Json<ActorsListResponse> {
    state.touch_activity();
    let entries = match query.project {
        Some(ref project) => state.actors.list_for_project(project),
        None => state.actors.list(),
    };
    let total = entries.len();
    let actors = entries
        .into_iter()
        .map(|(token, session)| ActorEntry { token, session })
        .collect();
    Json(ActorsListResponse { actors, total })
}

// ── Notification Endpoints ──────────────────────────────────────────────

#[derive(Deserialize)]
struct PendingQuery {
    token: String,
    project: Option<String>,
    #[serde(default)]
    check_only: bool,
}

#[derive(Serialize)]
struct PendingResponse {
    pending: usize,
    #[serde(skip_serializing_if = "Option::is_none")]
    notifications: Option<Vec<NotificationView>>,
}

async fn notifications_pending(
    State(state): State<AppState>,
    Query(query): Query<PendingQuery>,
) -> Json<PendingResponse> {
    state.touch_activity();
    state.actors.touch(&query.token);

    let project = query
        .project
        .unwrap_or_else(|| state.default_project_name());

    if query.check_only {
        let count = state.notifications.pending_count(&project, &query.token);
        Json(PendingResponse {
            pending: count,
            notifications: None,
        })
    } else {
        let notifications = state.notifications.pending(&project, &query.token);
        let count = notifications.len();
        Json(PendingResponse {
            pending: count,
            notifications: Some(notifications),
        })
    }
}

#[derive(Serialize)]
struct AckResponse {
    acknowledged: usize,
}

async fn notifications_ack(
    State(state): State<AppState>,
    Json(req): Json<AckRequest>,
) -> std::result::Result<Json<AckResponse>, (StatusCode, String)> {
    state.touch_activity();
    state.actors.touch(&req.token);

    let project = req.project.unwrap_or_else(|| state.default_project_name());

    let count = if req.all {
        let acked = state.notifications.ack_all(&project, &req.token);
        state.actors.clear_pending(&req.token);
        acked
    } else if let Some(id) = req.notification_id {
        if state.notifications.ack_one(&project, &req.token, id) {
            1
        } else {
            0
        }
    } else {
        return Err((
            StatusCode::BAD_REQUEST,
            "either 'all: true' or 'notification_id' must be specified".to_string(),
        ));
    };

    Ok(Json(AckResponse {
        acknowledged: count,
    }))
}

// ── Search ───────────────────────────────────────────────────────────────

#[derive(Deserialize)]
struct SearchQuery {
    q: Option<String>,
    #[cfg(feature = "ui")]
    slug: Option<String>,
}

#[derive(Serialize)]
struct SearchResponse {
    query: String,
    results: Vec<crate::search::SearchResult>,
}

async fn search(
    State(state): State<AppState>,
    Query(params): Query<SearchQuery>,
) -> impl IntoResponse {
    let q = params.q.as_deref().unwrap_or("").trim().to_string();
    if q.is_empty() {
        return Json(SearchResponse {
            query: q,
            results: vec![],
        });
    }

    // In multi-project mode, resolve context from slug; fall back to primary
    // project.
    #[cfg(feature = "ui")]
    let results = {
        if let (Some(slug), Some(ref registry)) = (params.slug.as_deref(), &state.registry) {
            if let Some(ctx) = registry.get(slug) {
                crate::search::search_project(&ctx.root, &ctx.cache, ctx.import_path.as_deref(), &q)
                    .await
            } else {
                vec![]
            }
        } else {
            crate::search::search_project(
                &state.project_root,
                &state.cache,
                state.nickel_import_path.as_deref(),
                &q,
            )
            .await
        }
    };

    #[cfg(not(feature = "ui"))]
    let results = crate::search::search_project(
        &state.project_root,
        &state.cache,
        state.nickel_import_path.as_deref(),
        &q,
    )
    .await;

    Json(SearchResponse { query: q, results })
}

// ── Describe Endpoints ───────────────────────────────────────────────────

#[derive(Deserialize)]
struct DescribeQuery {
    slug: Option<String>,
}

#[derive(Deserialize)]
struct ActorInitQuery {
    actor: Option<String>,
    slug: Option<String>,
}

/// Resolve project context from an optional slug.
/// Falls back to the primary project when slug is absent or not found in
/// registry.
#[cfg(feature = "ui")]
fn resolve_project_ctx(
    state: &AppState,
    slug: Option<&str>,
) -> (PathBuf, Arc<NclCache>, Option<String>) {
    if let Some(s) = slug {
        if let Some(ref registry) = state.registry {
            if let Some(ctx) = registry.get(s) {
                return (ctx.root.clone(), ctx.cache.clone(), ctx.import_path.clone());
            }
        }
    }
    (
        state.project_root.clone(),
        state.cache.clone(),
        state.nickel_import_path.clone(),
    )
}

#[cfg(not(feature = "ui"))]
fn resolve_project_ctx(
    state: &AppState,
    _slug: Option<&str>,
) -> (PathBuf, Arc<NclCache>, Option<String>) {
    (
        state.project_root.clone(),
        state.cache.clone(),
        state.nickel_import_path.clone(),
    )
}

async fn describe_project(
    State(state): State<AppState>,
    Query(q): Query<DescribeQuery>,
) -> impl IntoResponse {
    state.touch_activity();
    let (root, cache, import_path) = resolve_project_ctx(&state, q.slug.as_deref());
    let path = root.join(".ontology").join("core.ncl");
    if !path.exists() {
        return (
            StatusCode::NOT_FOUND,
            Json(serde_json::json!({ "error": "core.ncl not found" })),
        );
    }
    match cache.export(&path, import_path.as_deref()).await {
        Ok((data, _)) => (StatusCode::OK, Json(data)),
        Err(e) => {
            error!(path = %path.display(), error = %e, "describe_project export failed");
            (
                StatusCode::INTERNAL_SERVER_ERROR,
                Json(serde_json::json!({ "error": e.to_string() })),
            )
        }
    }
}

async fn describe_connections(
    State(state): State<AppState>,
    Query(q): Query<DescribeQuery>,
) -> impl IntoResponse {
    state.touch_activity();
    let (root, cache, import_path) = resolve_project_ctx(&state, q.slug.as_deref());
    let path = root.join(".ontology").join("connections.ncl");
    if !path.exists() {
        return (
            StatusCode::NOT_FOUND,
            Json(serde_json::json!({ "error": "connections.ncl not found" })),
        );
    }
    match cache.export(&path, import_path.as_deref()).await {
        Ok((data, _)) => (StatusCode::OK, Json(data)),
        Err(e) => {
            error!(path = %path.display(), error = %e, "describe_connections export failed");
            (
                StatusCode::INTERNAL_SERVER_ERROR,
                Json(serde_json::json!({ "error": e.to_string() })),
            )
        }
    }
}

async fn describe_capabilities(
    State(state): State<AppState>,
    Query(q): Query<DescribeQuery>,
) -> impl IntoResponse {
    state.touch_activity();
    let (root, cache, import_path) = resolve_project_ctx(&state, q.slug.as_deref());

    let modes_dir = root.join("reflection").join("modes");
    let adrs_dir = root.join("adrs");
    let forms_dir = root.join("reflection").join("forms");

    // Modes: export each NCL for id + trigger
    let mut modes: Vec<serde_json::Value> = Vec::new();
    if let Ok(entries) = std::fs::read_dir(&modes_dir) {
        for entry in entries.flatten() {
            let path = entry.path();
            if path.extension().and_then(|e| e.to_str()) != Some("ncl") {
                continue;
            }
            let stem = path
                .file_stem()
                .and_then(|s| s.to_str())
                .unwrap_or("")
                .to_string();
            match cache.export(&path, import_path.as_deref()).await {
                Ok((json, _)) => {
                    let id = json
                        .get("id")
                        .and_then(|v| v.as_str())
                        .unwrap_or(&stem)
                        .to_string();
                    let trigger = json
                        .get("trigger")
                        .and_then(|v| v.as_str())
                        .unwrap_or("")
                        .to_string();
                    modes.push(serde_json::json!({ "id": id, "trigger": trigger }));
                }
                Err(e) => {
                    warn!(path = %path.display(), error = %e, "capabilities: mode export failed");
                    modes.push(serde_json::json!({ "id": stem, "trigger": "" }));
                }
            }
        }
    }
    modes.sort_by_key(|v| v["id"].as_str().unwrap_or("").to_string());

    // ADRs: count only
    let adr_count = std::fs::read_dir(&adrs_dir)
        .map(|rd| {
            rd.flatten()
                .filter(|e| e.path().extension().and_then(|x| x.to_str()) == Some("ncl"))
                .count()
        })
        .unwrap_or(0);

    // Forms: list file stems
    let mut forms: Vec<String> = std::fs::read_dir(&forms_dir)
        .map(|rd| {
            rd.flatten()
                .filter_map(|e| {
                    let p = e.path();
                    if p.extension().and_then(|x| x.to_str()) != Some("ncl") {
                        return None;
                    }
                    p.file_stem().and_then(|s| s.to_str()).map(str::to_string)
                })
                .collect()
        })
        .unwrap_or_default();
    forms.sort();

    let mode_count = modes.len();
    let form_count = forms.len();

    (
        StatusCode::OK,
        Json(serde_json::json!({
            "modes":      modes,
            "adrs":       adr_count,
            "forms":      forms,
            "mode_count": mode_count,
            "adr_count":  adr_count,
            "form_count": form_count,
        })),
    )
}

async fn describe_actor_init(
    State(state): State<AppState>,
    Query(q): Query<ActorInitQuery>,
) -> impl IntoResponse {
    state.touch_activity();
    let (root, cache, import_path) = resolve_project_ctx(&state, q.slug.as_deref());
    let actor = q.actor.as_deref().unwrap_or("agent");
    let config_path = root.join(".ontoref").join("config.ncl");

    if !config_path.exists() {
        return (
            StatusCode::OK,
            Json(serde_json::json!({ "mode": "", "auto_run": false })),
        );
    }

    match cache.export(&config_path, import_path.as_deref()).await {
        Ok((json, _)) => {
            let entry = json
                .get("actor_init")
                .and_then(|v| v.as_array())
                .and_then(|arr| {
                    arr.iter()
                        .find(|e| e.get("actor").and_then(|v| v.as_str()) == Some(actor))
                })
                .cloned();

            let result = match entry {
                Some(e) => {
                    let mode = e
                        .get("mode")
                        .and_then(|v| v.as_str())
                        .unwrap_or("")
                        .to_string();
                    let auto_run = e.get("auto_run").and_then(|v| v.as_bool()).unwrap_or(false);
                    serde_json::json!({ "mode": mode, "auto_run": auto_run })
                }
                None => serde_json::json!({ "mode": "", "auto_run": false }),
            };
            (StatusCode::OK, Json(result))
        }
        Err(e) => {
            warn!(path = %config_path.display(), error = %e, "describe_actor_init export failed");
            (
                StatusCode::OK,
                Json(serde_json::json!({ "mode": "", "auto_run": false })),
            )
        }
    }
}

async fn backlog_json(
    State(state): State<AppState>,
    Query(q): Query<DescribeQuery>,
) -> impl IntoResponse {
    state.touch_activity();
    let (root, cache, import_path) = resolve_project_ctx(&state, q.slug.as_deref());
    let backlog_path = root.join("reflection").join("backlog.ncl");

    if !backlog_path.exists() {
        return (StatusCode::OK, Json(serde_json::json!([])));
    }

    match cache.export(&backlog_path, import_path.as_deref()).await {
        Ok((json, _)) => {
            let items = json
                .get("items")
                .and_then(|v| v.as_array())
                .cloned()
                .unwrap_or_default();
            (StatusCode::OK, Json(serde_json::Value::Array(items)))
        }
        Err(e) => {
            error!(path = %backlog_path.display(), error = %e, "backlog_json export failed");
            (
                StatusCode::INTERNAL_SERVER_ERROR,
                Json(serde_json::json!({ "error": e.to_string() })),
            )
        }
    }
}

// ── Q&A endpoints ───────────────────────────────────────────────────────

async fn qa_json(
    State(state): State<AppState>,
    Query(q): Query<DescribeQuery>,
) -> impl IntoResponse {
    state.touch_activity();
    let (root, cache, import_path) = resolve_project_ctx(&state, q.slug.as_deref());
    let qa_path = root.join("reflection").join("qa.ncl");

    if !qa_path.exists() {
        return (StatusCode::OK, Json(serde_json::json!([])));
    }

    match cache.export(&qa_path, import_path.as_deref()).await {
        Ok((json, _)) => {
            let entries = json
                .get("entries")
                .and_then(|v| v.as_array())
                .cloned()
                .unwrap_or_default();
            (StatusCode::OK, Json(serde_json::Value::Array(entries)))
        }
        Err(e) => {
            error!(path = %qa_path.display(), error = %e, "qa_json export failed");
            (
                StatusCode::INTERNAL_SERVER_ERROR,
                Json(serde_json::json!({ "error": e.to_string() })),
            )
        }
    }
}

// ── Helpers ─────────────────────────────────────────────────────────────

/// Resolve a path that may be absolute or relative to project_root.
/// Absolute paths are accepted as-is (daemon is loopback-only; OS enforces
/// access). Relative paths are resolved against project_root and must stay
/// within it.
fn resolve_any_path(
    project_root: &std::path::Path,
    path: &str,
) -> std::result::Result<PathBuf, (StatusCode, String)> {
    let p = PathBuf::from(path);
    if p.is_absolute() {
        return p.canonicalize().map_err(|e| {
            (
                StatusCode::BAD_REQUEST,
                format!("path does not exist or is inaccessible: {e}"),
            )
        });
    }
    let joined = project_root.join(p);
    let canonical = joined.canonicalize().map_err(|e| {
        (
            StatusCode::BAD_REQUEST,
            format!("path does not exist or is inaccessible: {e}"),
        )
    })?;
    if !canonical.starts_with(project_root) {
        return Err((
            StatusCode::BAD_REQUEST,
            format!("path escapes project root: {path}"),
        ));
    }
    Ok(canonical)
}

/// Resolve a path relative to the project root and verify it stays within.
/// Rejects absolute paths — used for cache management operations scoped to a
/// project.
fn resolve_path(
    project_root: &std::path::Path,
    path: &str,
) -> std::result::Result<PathBuf, (StatusCode, String)> {
    let p = PathBuf::from(path);
    if p.is_absolute() {
        return Err((
            StatusCode::BAD_REQUEST,
            "absolute paths are not accepted".to_string(),
        ));
    }
    let joined = project_root.join(p);
    let canonical = joined.canonicalize().map_err(|e| {
        (
            StatusCode::BAD_REQUEST,
            format!("path does not exist or is inaccessible: {e}"),
        )
    })?;
    if !canonical.starts_with(project_root) {
        return Err((
            StatusCode::BAD_REQUEST,
            format!("path escapes project root: {path}"),
        ));
    }
    Ok(canonical)
}

impl IntoResponse for crate::error::DaemonError {
    fn into_response(self) -> axum::response::Response {
        let body = serde_json::json!({"error": self.to_string()});
        (StatusCode::INTERNAL_SERVER_ERROR, Json(body)).into_response()
    }
}
