//! Bot command HTTP endpoint.
//!
//! `POST /api/bot/command` lets the web UI invoke the same deterministic bot
//! commands available in Matrix without going through the LLM.
//!
//! Synchronous commands (status, git, cost, move, show, overview, help) are
//! dispatched directly through the matrix command registry.
//! Asynchronous commands (assign, start, delete, rebuild) are dispatched to
//! their dedicated async handlers.  The `reset` command is handled by the frontend
//! (it clears local session state and message history) and is not routed here.

use crate::http::context::{AppContext, OpenApiResult};
use crate::chat::commands::CommandDispatch;
use poem::http::StatusCode;
use poem_openapi::{Object, OpenApi, Tags, payload::Json};
use serde::{Deserialize, Serialize};
use std::collections::HashSet;
use std::sync::{Arc, Mutex};

#[derive(Tags)]
enum BotCommandTags {
    BotCommand,
}

/// Body for `POST /api/bot/command`.
#[derive(Object, Deserialize)]
struct BotCommandRequest {
    /// The command keyword without the leading slash (e.g. `"status"`, `"start"`).
    command: String,
    /// Any text after the command keyword, trimmed (may be empty).
    #[oai(default)]
    args: String,
}

/// Response body for `POST /api/bot/command`.
#[derive(Object, Serialize)]
struct BotCommandResponse {
    /// Markdown-formatted response text.
    response: String,
}

pub struct BotCommandApi {
    pub ctx: Arc<AppContext>,
}

#[OpenApi(tag = "BotCommandTags::BotCommand")]
impl BotCommandApi {
    /// Execute a slash command without LLM invocation.
    ///
    /// Dispatches to the same handlers used by the Matrix and Slack bots.
    /// Returns a markdown-formatted response that the frontend can display
    /// directly in the chat panel.
    #[oai(path = "/bot/command", method = "post")]
    async fn run_command(
        &self,
        body: Json<BotCommandRequest>,
    ) -> OpenApiResult<Json<BotCommandResponse>> {
        let project_root = self.ctx.state.get_project_root().map_err(|e| {
            poem::Error::from_string(e, StatusCode::BAD_REQUEST)
        })?;

        let cmd = body.command.trim().to_ascii_lowercase();
        let args = body.args.trim();
        let response = dispatch_command(&cmd, args, &project_root, &self.ctx.agents).await;

        Ok(Json(BotCommandResponse { response }))
    }
}

/// Dispatch a command keyword + args to the appropriate handler.
async fn dispatch_command(
    cmd: &str,
    args: &str,
    project_root: &std::path::Path,
    agents: &Arc<crate::agents::AgentPool>,
) -> String {
    match cmd {
        "assign" => dispatch_assign(args, project_root, agents).await,
        "start" => dispatch_start(args, project_root, agents).await,
        "delete" => dispatch_delete(args, project_root, agents).await,
        "rebuild" => dispatch_rebuild(project_root, agents).await,
        // All other commands go through the synchronous command registry.
        _ => dispatch_sync(cmd, args, project_root, agents),
    }
}

fn dispatch_sync(
    cmd: &str,
    args: &str,
    project_root: &std::path::Path,
    agents: &Arc<crate::agents::AgentPool>,
) -> String {
    let ambient_rooms: Arc<Mutex<HashSet<String>>> = Arc::new(Mutex::new(HashSet::new()));
    // Use a synthetic bot name/id so strip_bot_mention passes through.
    let bot_name = "__web_ui__";
    let bot_user_id = "@__web_ui__:localhost";
    let room_id = "__web_ui__";

    let dispatch = CommandDispatch {
        bot_name,
        bot_user_id,
        project_root,
        agents,
        ambient_rooms: &ambient_rooms,
        room_id,
    };

    // Build a synthetic message that the registry can parse.
    let synthetic = if args.is_empty() {
        format!("{bot_name} {cmd}")
    } else {
        format!("{bot_name} {cmd} {args}")
    };

    match crate::chat::commands::try_handle_command(&dispatch, &synthetic) {
        Some(response) => response,
        None => {
            // Command exists in the registry but its fallback handler returns None
            // (start, delete, rebuild, reset, htop — handled elsewhere or in
            // the frontend). Should not be reached for those since we intercept
            // them above.  For genuinely unknown commands, tell the user.
            format!("Unknown command: `/{cmd}`. Type `/help` to see available commands.")
        }
    }
}

async fn dispatch_assign(
    args: &str,
    project_root: &std::path::Path,
    agents: &Arc<crate::agents::AgentPool>,
) -> String {
    // args: "<number> <model>"
    let mut parts = args.splitn(2, char::is_whitespace);
    let number_str = parts.next().unwrap_or("").trim();
    let model_str = parts.next().unwrap_or("").trim();

    if number_str.is_empty() || !number_str.chars().all(|c| c.is_ascii_digit()) || model_str.is_empty() {
        return "Usage: `/assign <number> <model>` (e.g. `/assign 42 opus`)".to_string();
    }

    crate::chat::transport::matrix::assign::handle_assign("web-ui", number_str, model_str, project_root, agents)
        .await
}

async fn dispatch_start(
    args: &str,
    project_root: &std::path::Path,
    agents: &Arc<crate::agents::AgentPool>,
) -> String {
    // args: "<number>" or "<number> <model_hint>"
    let mut parts = args.splitn(2, char::is_whitespace);
    let number_str = parts.next().unwrap_or("").trim();
    let hint_str = parts.next().unwrap_or("").trim();

    if number_str.is_empty() || !number_str.chars().all(|c| c.is_ascii_digit()) {
        return "Usage: `/start <number>` or `/start <number> <model>` (e.g. `/start 42 opus`)"
            .to_string();
    }

    let agent_hint = if hint_str.is_empty() {
        None
    } else {
        Some(hint_str)
    };

    crate::chat::transport::matrix::start::handle_start("web-ui", number_str, agent_hint, project_root, agents)
        .await
}

async fn dispatch_delete(
    args: &str,
    project_root: &std::path::Path,
    agents: &Arc<crate::agents::AgentPool>,
) -> String {
    let number_str = args.trim();
    if number_str.is_empty() || !number_str.chars().all(|c| c.is_ascii_digit()) {
        return "Usage: `/delete <number>` (e.g. `/delete 42`)".to_string();
    }
    crate::chat::transport::matrix::delete::handle_delete("web-ui", number_str, project_root, agents).await
}

async fn dispatch_rebuild(
    project_root: &std::path::Path,
    agents: &Arc<crate::agents::AgentPool>,
) -> String {
    crate::chat::transport::matrix::rebuild::handle_rebuild("web-ui", project_root, agents).await
}

// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------

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

    fn test_api(dir: &TempDir) -> BotCommandApi {
        BotCommandApi {
            ctx: Arc::new(AppContext::new_test(dir.path().to_path_buf())),
        }
    }

    #[tokio::test]
    async fn help_command_returns_response() {
        let dir = TempDir::new().unwrap();
        let api = test_api(&dir);
        let body = BotCommandRequest {
            command: "help".to_string(),
            args: String::new(),
        };
        let result = api.run_command(Json(body)).await;
        assert!(result.is_ok());
        let resp = result.unwrap().0;
        assert!(!resp.response.is_empty());
    }

    #[tokio::test]
    async fn unknown_command_returns_error_message() {
        let dir = TempDir::new().unwrap();
        let api = test_api(&dir);
        let body = BotCommandRequest {
            command: "nonexistent_xyz".to_string(),
            args: String::new(),
        };
        let result = api.run_command(Json(body)).await;
        assert!(result.is_ok());
        let resp = result.unwrap().0;
        assert!(
            resp.response.contains("Unknown command"),
            "expected 'Unknown command' in: {}",
            resp.response
        );
    }

    #[tokio::test]
    async fn start_without_number_returns_usage() {
        let dir = TempDir::new().unwrap();
        let api = test_api(&dir);
        let body = BotCommandRequest {
            command: "start".to_string(),
            args: String::new(),
        };
        let result = api.run_command(Json(body)).await;
        assert!(result.is_ok());
        let resp = result.unwrap().0;
        assert!(
            resp.response.contains("Usage"),
            "expected usage hint in: {}",
            resp.response
        );
    }

    #[tokio::test]
    async fn delete_without_number_returns_usage() {
        let dir = TempDir::new().unwrap();
        let api = test_api(&dir);
        let body = BotCommandRequest {
            command: "delete".to_string(),
            args: String::new(),
        };
        let result = api.run_command(Json(body)).await;
        assert!(result.is_ok());
        let resp = result.unwrap().0;
        assert!(
            resp.response.contains("Usage"),
            "expected usage hint in: {}",
            resp.response
        );
    }

    #[tokio::test]
    async fn git_command_returns_response() {
        let dir = TempDir::new().unwrap();
        // Initialise a bare git repo so the git command has something to query.
        std::process::Command::new("git")
            .args(["init"])
            .current_dir(dir.path())
            .output()
            .ok();
        let api = test_api(&dir);
        let body = BotCommandRequest {
            command: "git".to_string(),
            args: String::new(),
        };
        let result = api.run_command(Json(body)).await;
        assert!(result.is_ok());
    }

    #[tokio::test]
    async fn run_command_requires_project_root() {
        // Create a context with no project root set.
        let dir = TempDir::new().unwrap();
        let ctx = AppContext::new_test(dir.path().to_path_buf());
        // Clear the project root.
        *ctx.state.project_root.lock().unwrap() = None;
        let api = BotCommandApi { ctx: Arc::new(ctx) };
        let body = BotCommandRequest {
            command: "status".to_string(),
            args: String::new(),
        };
        let result = api.run_command(Json(body)).await;
        assert!(result.is_err(), "should fail when no project root is set");
    }
}
