//! `/v1/agents/{agent_path}/sessions` — session lifecycle routes.
//!
//! - `POST /v1/agents/{agent_path}/sessions` — start a new session turn.
//! - `POST /v1/agents/{agent_path}/sessions/{key}:cancel` — SIGTERM the
//!   running CLI for that session_key.
//!
//! `agent_path` is a single path segment and MUST be percent-encoded by the
//! caller if it contains slashes (normal for absolute paths like
//! `/Users/x/Documents/Houston/ws/agent` → `%2FUsers%2Fx%2F…`).
//!
//! Sessions stream updates over the WebSocket on the `session:{key}` topic.
//! Clients are responsible for subscribing to the topic BEFORE calling this
//! route (race-free: the forwarder drops events that arrive before
//! subscription, which is safe because the caller already has the
//! session_key echoed back from this response).

use crate::routes::error::ApiError;
use crate::state::ServerState;
use axum::{
    extract::{Path, State},
    routing::{get, post},
    Json, Router,
};
use houston_composio::toolkit_display_name;
use houston_engine_core::sessions::{
    self, fallback_provider, generate_instructions, history, resolve_agent_dir, resolve_provider,
    summarize, SessionRuntime, StartParams,
};
use houston_engine_core::CoreError;
use houston_terminal_manager::Provider;
use serde::{Deserialize, Serialize};
use std::sync::Arc;

pub fn router() -> Router<Arc<ServerState>> {
    Router::new()
        .route(
            "/agents/:agent_path/sessions",
            post(start_session),
        )
        .route(
            "/agents/:agent_path/sessions/onboarding",
            post(start_onboarding),
        )
        .route(
            "/agents/:agent_path/sessions/:key_action",
            post(cancel_session),
        )
        .route(
            "/agents/:agent_path/sessions/:key/history",
            get(load_history),
        )
        .route("/sessions/summarize", post(summarize_activity))
        .route(
            "/sessions/generate-instructions",
            post(generate_agent_instructions),
        )
}

#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct StartRequest {
    /// Stable conversation slot id. Required — the frontend owns this.
    pub session_key: String,
    pub prompt: String,
    /// Optional pre-built system prompt. Caller (Tauri adapter today) assembles
    /// CLAUDE.md + seed + integration guidance and passes the final string.
    #[serde(default)]
    pub system_prompt: Option<String>,
    #[serde(default)]
    pub source: Option<String>,
    /// Working-directory override. Defaults to `agent_dir`.
    #[serde(default)]
    pub working_dir: Option<String>,
    /// Provider override (`"anthropic"` or `"openai"`). Wins over the
    /// agent/workspace-resolved provider.
    #[serde(default)]
    pub provider: Option<String>,
    /// Model override. Wins over any resolved default.
    #[serde(default)]
    pub model: Option<String>,
    /// Reasoning effort override. Forwarded to the CLI as `--effort` (Claude)
    /// or `-c model_reasoning_effort=<value>` (Codex). Used by the onboarding
    /// tutorial to force a known-good value regardless of what the user has
    /// in `~/.codex/config.toml`.
    #[serde(default)]
    pub effort: Option<String>,
    /// When `true`, compact the conversation (summarize + reseed a fresh
    /// provider session) before running this turn. The frontend sets this
    /// once the context window is nearly full. Defaults to `false` so older
    /// clients deserialize unchanged.
    #[serde(default)]
    pub compact: bool,
    /// Set when the user switched this conversation to a DIFFERENT provider
    /// mid-stream. Reseeds a fresh session on the new provider with prior
    /// context (verbatim or summarized — see [`ProviderSwitchRequest`]). Takes
    /// precedence over `compact`. Absent for normal turns.
    #[serde(default)]
    pub provider_switch: Option<ProviderSwitchRequest>,
}

#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ProviderSwitchRequest {
    /// `"replay"` (carry the full transcript verbatim) or `"summarize"`
    /// (AI-summarize to fit the new provider's smaller window).
    pub mode: String,
    /// Provider id being LEFT (e.g. `"anthropic"`). The provider being switched
    /// TO is `provider`. Used to anchor the boundary divider in history.
    pub from_provider: String,
}

#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct StartResponse {
    pub session_key: String,
}

async fn start_session(
    State(st): State<Arc<ServerState>>,
    Path(agent_path): Path<String>,
    Json(req): Json<StartRequest>,
) -> Result<Json<StartResponse>, ApiError> {
    let agent_dir = resolve_agent_dir(&st.engine.paths, &agent_path);
    let working_dir = req
        .working_dir
        .as_deref()
        .map(|p| sessions::expand_tilde(std::path::Path::new(p)))
        .unwrap_or_else(|| agent_dir.clone());

    // Override > agent config > authenticated fallback. Shared with routine
    // dispatch via `sessions::resolve_provider_with_overrides`.
    let resolved = sessions::resolve_provider_with_overrides(
        &st.engine.db,
        &agent_dir,
        req.provider.as_deref(),
        req.model.clone(),
    )
    .await
    .map_err(CoreError::BadRequest)?;
    let (provider, model) = (resolved.provider, resolved.model);

    // Reasoning effort: an explicit request override wins (the onboarding
    // tutorial forces a known-good value); otherwise resolve the agent's
    // configured effort, validated against the *final* provider.
    let effort = req
        .effort
        .or_else(|| sessions::resolve_effort(&agent_dir, provider));

    // Parse the optional provider-switch handoff. Invalid mode / provider is a
    // client error, not a silent drop.
    let handoff = match req.provider_switch {
        Some(sw) => {
            let mode: sessions::SeedMode =
                sw.mode.parse().map_err(|e: String| CoreError::BadRequest(e))?;
            let from_provider: Provider = sw
                .from_provider
                .parse()
                .map_err(|e: String| CoreError::BadRequest(e))?;
            Some(sessions::ProviderHandoff {
                mode,
                from_provider,
            })
        }
        None => None,
    };

    let params = StartParams {
        agent_dir,
        working_dir,
        session_key: req.session_key.clone(),
        prompt: req.prompt,
        system_prompt: req.system_prompt,
        source: req.source,
        provider,
        model,
        effort,
        compact: req.compact,
        handoff,
    };

    let rt = SessionRuntime::clone(&st.engine.sessions);
    let sink = st.engine.events.clone();
    let db = st.engine.db.clone();
    let key = sessions::start(&rt, sink, db, &st.engine.app_system_prompt, params).await?;

    Ok(Json(StartResponse { session_key: key }))
}

#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct CancelResponse {
    pub cancelled: bool,
}

#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct OnboardingRequest {
    pub session_key: String,
}

async fn start_onboarding(
    State(st): State<Arc<ServerState>>,
    Path(agent_path): Path<String>,
    Json(req): Json<OnboardingRequest>,
) -> Result<Json<StartResponse>, ApiError> {
    let agent_dir = resolve_agent_dir(&st.engine.paths, &agent_path);
    let rt = SessionRuntime::clone(&st.engine.sessions);
    let sink = st.engine.events.clone();
    let db = st.engine.db.clone();
    let key = sessions::start_onboarding(
        &rt,
        sink,
        db,
        &st.engine.app_system_prompt,
        &st.engine.app_onboarding_prompt,
        agent_dir,
        req.session_key,
    )
    .await?;
    Ok(Json(StartResponse { session_key: key }))
}

async fn load_history(
    State(st): State<Arc<ServerState>>,
    Path((agent_path, key)): Path<(String, String)>,
) -> Result<Json<Vec<history::ChatHistoryEntry>>, ApiError> {
    let agent_dir = resolve_agent_dir(&st.engine.paths, &agent_path);
    Ok(Json(history::load(&st.engine.db, &agent_dir, &key).await?))
}

#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SummarizeRequest {
    pub message: String,
    #[serde(default)]
    pub agent_path: Option<String>,
    #[serde(default)]
    pub provider: Option<String>,
    #[serde(default)]
    pub model: Option<String>,
}

async fn summarize_activity(
    State(st): State<Arc<ServerState>>,
    Json(req): Json<SummarizeRequest>,
) -> Result<Json<summarize::SummarizeResult>, ApiError> {
    let (provider, model) = if let Some(p_str) = req.provider.as_deref() {
        let provider = p_str
            .parse()
            .map_err(|e: String| CoreError::BadRequest(e))?;
        (provider, req.model)
    } else if let Some(agent_path) = req.agent_path.as_deref() {
        let agent_dir = resolve_agent_dir(&st.engine.paths, agent_path);
        let resolved = resolve_provider(&st.engine.db, &agent_dir).await;
        (resolved.provider, req.model.or(resolved.model))
    } else {
        // No provider + no agent to resolve against: auth-aware fallback so an
        // OpenAI-only user's title summary doesn't spawn the Claude CLI (#483).
        (fallback_provider(&st.engine.db).await, req.model)
    };
    Ok(Json(
        summarize::summarize(&req.message, provider, model.as_deref()).await?,
    ))
}

#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct GenerateInstructionsRequest {
    pub description: String,
    #[serde(default)]
    pub provider: Option<String>,
    #[serde(default)]
    pub model: Option<String>,
}

#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct SuggestedIntegration {
    slug: String,
    display_name: String,
}

fn resolve_suggested_integration(raw: String) -> SuggestedIntegration {
    let slug = raw.to_lowercase();
    let display_name = toolkit_display_name(&slug).to_string();
    SuggestedIntegration { slug, display_name }
}

#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct GenerateInstructionsResponse {
    name: String,
    instructions: String,
    suggested_integrations: Vec<SuggestedIntegration>,
    // Passthrough: core already built + validated the cron, no resolution needed.
    #[serde(skip_serializing_if = "Option::is_none")]
    suggested_routine: Option<generate_instructions::SuggestedRoutine>,
}

async fn generate_agent_instructions(
    State(st): State<Arc<ServerState>>,
    Json(req): Json<GenerateInstructionsRequest>,
) -> Result<Json<GenerateInstructionsResponse>, ApiError> {
    let (provider, model) = if let Some(p_str) = req.provider.as_deref() {
        let provider = p_str
            .parse()
            .map_err(|e: String| CoreError::BadRequest(e))?;
        (provider, req.model)
    } else {
        // No override: auth-aware fallback (same as summarize) so generating an
        // agent's instructions for an OpenAI-only user doesn't spawn Claude (#483).
        (fallback_provider(&st.engine.db).await, req.model)
    };
    let result =
        generate_instructions::generate_instructions(&req.description, provider, model.as_deref())
            .await?;
    let suggested_integrations = result
        .suggested_integrations
        .into_iter()
        .map(resolve_suggested_integration)
        .collect();
    Ok(Json(GenerateInstructionsResponse {
        name: result.name,
        instructions: result.instructions,
        suggested_integrations,
        suggested_routine: result.suggested_routine,
    }))
}

async fn cancel_session(
    State(st): State<Arc<ServerState>>,
    Path((agent_path, key_action)): Path<(String, String)>,
) -> Result<Json<CancelResponse>, ApiError> {
    let session_key = match key_action.strip_suffix(":cancel") {
        Some(k) if !k.is_empty() => k.to_string(),
        _ => {
            return Err(CoreError::BadRequest(format!(
                "path action must be `:cancel`, got {key_action:?}"
            ))
            .into());
        }
    };
    let agent_dir = resolve_agent_dir(&st.engine.paths, &agent_path);
    let agent_path_str = agent_dir.to_string_lossy().to_string();

    let cancelled = sessions::cancel(
        &st.engine.sessions,
        &st.engine.events,
        &agent_path_str,
        &session_key,
    )
    .await;
    Ok(Json(CancelResponse { cancelled }))
}

// ---------------------------------------------------------------------------

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

    #[test]
    fn resolve_normalizes_uppercase_slug() {
        let result = resolve_suggested_integration("GMAIL".to_string());
        assert_eq!(result.slug, "gmail");
        assert_eq!(result.display_name, "Gmail");
    }

    #[test]
    fn resolve_normalizes_mixed_case_slug() {
        let result = resolve_suggested_integration("GoogleCalendar".to_string());
        assert_eq!(result.slug, "googlecalendar");
        assert_eq!(result.display_name, "Google Calendar");
    }

    #[test]
    fn resolve_unknown_slug_uses_slug_as_display_name() {
        let result = resolve_suggested_integration("UNKNOWNTOOL".to_string());
        assert_eq!(result.slug, "unknowntool");
        assert_eq!(result.display_name, "unknowntool");
    }
}
