//! Gateway service — domain logic for the multi-project gateway.
//!
//! Follows the conventions in `docs/architecture/service-modules.md`:
//! - `mod.rs` (this file) — public API, typed [`Error`], orchestration, `GatewayState`
//! - `io.rs` — the ONLY place that performs side effects (filesystem, network, process spawn)
//! - `config.rs` — pure config types and validation
//! - `registration.rs` — pure agent registration logic
//! - `aggregation.rs` — pure cross-project pipeline formatting
//! - `polling.rs` — pure notification event formatting

pub mod aggregation;
pub mod config;
pub(crate) mod io;
pub mod polling;
pub mod registration;

pub use aggregation::format_aggregate_status_compact;
pub use config::{GatewayConfig, ProjectEntry};
pub use io::{fetch_all_project_pipeline_statuses, spawn_gateway_notification_poller};
pub use registration::JoinedAgent;

use io::Client;
use std::collections::{BTreeMap, HashMap};
use std::path::PathBuf;
use std::sync::Arc;
use tokio::sync::Mutex as TokioMutex;
use tokio::sync::RwLock;

// ── Error type ──────────────────────────────────────────────────────────────

/// Typed errors returned by `service::gateway` functions.
///
/// HTTP handlers map these to appropriate status codes:
/// - [`Error::ProjectNotFound`] → 404 Not Found
/// - [`Error::UnreachableProject`] → 502 Bad Gateway
/// - [`Error::DuplicateToken`] → 409 Conflict
/// - [`Error::InvalidAgent`] → 404 Not Found / 400 Bad Request
/// - [`Error::Config`] → 400 Bad Request
/// - [`Error::Upstream`] → 502 Bad Gateway
#[derive(Debug)]
pub enum Error {
    /// A referenced project does not exist in the gateway config.
    ProjectNotFound(String),
    /// A project container is unreachable.
    UnreachableProject(String),
    /// A join token has already been consumed or a project name is taken.
    DuplicateToken(String),
    /// An agent ID is invalid or not found.
    InvalidAgent(String),
    /// A configuration value is invalid.
    Config(String),
    /// An upstream project container returned an unexpected response.
    Upstream(String),
}

impl std::fmt::Display for Error {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            Self::ProjectNotFound(msg) => write!(f, "Project not found: {msg}"),
            Self::UnreachableProject(msg) => write!(f, "Unreachable project: {msg}"),
            Self::DuplicateToken(msg) => write!(f, "Duplicate token: {msg}"),
            Self::InvalidAgent(msg) => write!(f, "Invalid agent: {msg}"),
            Self::Config(msg) => write!(f, "Config error: {msg}"),
            Self::Upstream(msg) => write!(f, "Upstream error: {msg}"),
        }
    }
}

// ── Gateway state ───────────────────────────────────────────────────────────

/// A one-time join token that has been generated but not yet consumed.
pub(crate) struct PendingToken {
    #[allow(dead_code)]
    pub(crate) created_at: f64,
}

/// Shared gateway state threaded through HTTP handlers.
#[derive(Clone)]
pub struct GatewayState {
    /// The live set of registered projects (initially loaded from `projects.toml`).
    pub projects: Arc<RwLock<BTreeMap<String, ProjectEntry>>>,
    /// The currently active project name.
    pub active_project: Arc<RwLock<String>>,
    /// HTTP client for proxying requests to project containers.
    pub client: Client,
    /// Build agents that have joined this gateway.
    pub joined_agents: Arc<RwLock<Vec<JoinedAgent>>>,
    /// One-time join tokens that have been issued but not yet consumed.
    pub(crate) pending_tokens: Arc<RwLock<HashMap<String, PendingToken>>>,
    /// Directory containing `projects.toml` and the `.huskies/` subfolder.
    pub config_dir: PathBuf,
    /// HTTP port the gateway is listening on.
    pub port: u16,
    /// Abort handle for the running Matrix bot task (if any).
    pub bot_handle: Arc<TokioMutex<Option<tokio::task::AbortHandle>>>,
}

impl GatewayState {
    /// Create a new gateway state from a config and config directory.
    ///
    /// The first project in the config becomes the active project by default.
    /// Previously registered agents are loaded from `gateway_agents.json`.
    pub fn new(
        gateway_config: GatewayConfig,
        config_dir: PathBuf,
        port: u16,
    ) -> Result<Self, String> {
        let first = config::validate_config(&gateway_config)?;
        let agents = io::load_agents(&config_dir);
        Ok(Self {
            projects: Arc::new(RwLock::new(gateway_config.projects)),
            active_project: Arc::new(RwLock::new(first)),
            client: Client::new(),
            joined_agents: Arc::new(RwLock::new(agents)),
            pending_tokens: Arc::new(RwLock::new(HashMap::new())),
            config_dir,
            port,
            bot_handle: Arc::new(TokioMutex::new(None)),
        })
    }

    /// Get the URL of the currently active project.
    pub async fn active_url(&self) -> Result<String, Error> {
        let name = self.active_project.read().await.clone();
        self.projects
            .read()
            .await
            .get(&name)
            .map(|p| p.url.clone())
            .ok_or_else(|| {
                Error::ProjectNotFound(format!("active project '{name}' not found in config"))
            })
    }
}

// ── Public API ──────────────────────────────────────────────────────────────

/// Switch the active project. Returns the project's URL on success.
pub async fn switch_project(state: &GatewayState, project: &str) -> Result<String, Error> {
    if project.is_empty() {
        return Err(Error::Config("missing required parameter: project".into()));
    }

    let url = {
        let projects = state.projects.read().await;
        config::validate_project_exists(&projects, project).map_err(Error::ProjectNotFound)?
    };

    *state.active_project.write().await = project.to_string();
    Ok(url)
}

/// Generate a one-time join token. Returns the token string.
pub async fn generate_join_token(state: &GatewayState) -> String {
    let token = uuid::Uuid::new_v4().to_string();
    let now = chrono::Utc::now().timestamp() as f64;
    state
        .pending_tokens
        .write()
        .await
        .insert(token.clone(), PendingToken { created_at: now });
    crate::slog!("[gateway] Generated join token {:.8}…", &token);
    token
}

/// Register a build agent with a join token.
pub async fn register_agent(
    state: &GatewayState,
    token: &str,
    label: String,
    address: String,
) -> Result<JoinedAgent, Error> {
    // Validate and consume the token.
    let mut tokens = state.pending_tokens.write().await;
    if !tokens.contains_key(token) {
        return Err(Error::DuplicateToken(
            "invalid or already-used join token".into(),
        ));
    }
    tokens.remove(token);
    drop(tokens);

    let now = chrono::Utc::now().timestamp() as f64;
    let agent = registration::create_agent(uuid::Uuid::new_v4().to_string(), label, address, now);

    crate::slog!(
        "[gateway] Agent '{}' registered (id={})",
        agent.label,
        agent.id
    );

    {
        let mut agents = state.joined_agents.write().await;
        agents.push(agent.clone());
        io::save_agents(&agents, &state.config_dir).await;
    }

    Ok(agent)
}

/// Remove a registered agent by ID. Returns `true` if found and removed.
pub async fn remove_agent(state: &GatewayState, id: &str) -> bool {
    let mut agents = state.joined_agents.write().await;
    let removed = registration::remove_agent(&mut agents, id);
    if removed {
        io::save_agents(&agents, &state.config_dir).await;
        crate::slog!("[gateway] Removed agent id={id}");
    }
    removed
}

/// Assign or unassign an agent to a project.
pub async fn assign_agent(
    state: &GatewayState,
    id: &str,
    project: Option<String>,
) -> Result<JoinedAgent, Error> {
    let project_clean = project.and_then(|p| if p.is_empty() { None } else { Some(p) });

    let updated = {
        let projects = state.projects.read().await;
        let mut agents = state.joined_agents.write().await;
        registration::assign_agent(&mut agents, id, project_clean, &projects)?
    };

    crate::slog!(
        "[gateway] Agent '{}' (id={}) assigned to {:?}",
        updated.label,
        updated.id,
        updated.assigned_project
    );
    let agents = state.joined_agents.read().await.clone();
    io::save_agents(&agents, &state.config_dir).await;
    Ok(updated)
}

/// Update an agent's heartbeat. Returns `true` if found.
pub async fn heartbeat_agent(state: &GatewayState, id: &str) -> bool {
    let now = chrono::Utc::now().timestamp() as f64;
    let mut agents = state.joined_agents.write().await;
    registration::heartbeat(&mut agents, id, now)
}

/// Add a new project to the gateway config.
pub async fn add_project(state: &GatewayState, name: &str, url: &str) -> Result<(), Error> {
    let name = name.trim().to_string();
    let url = url.trim().to_string();

    if name.is_empty() {
        return Err(Error::Config("project name must not be empty".into()));
    }
    if url.is_empty() {
        return Err(Error::Config("project url must not be empty".into()));
    }

    {
        let mut projects = state.projects.write().await;
        if projects.contains_key(&name) {
            return Err(Error::DuplicateToken(format!(
                "project '{name}' already exists"
            )));
        }
        projects.insert(name.clone(), ProjectEntry { url: url.clone() });
    }

    let snapshot = state.projects.read().await.clone();
    io::save_config(&snapshot, &state.config_dir).await;
    crate::slog!("[gateway] Added project '{name}' ({url})");
    Ok(())
}

/// Remove a project from the gateway config.
pub async fn remove_project(state: &GatewayState, name: &str) -> Result<(), Error> {
    let active = state.active_project.read().await.clone();

    {
        let mut projects = state.projects.write().await;
        if !projects.contains_key(name) {
            return Err(Error::ProjectNotFound(format!(
                "project '{name}' not found"
            )));
        }
        if projects.len() == 1 {
            return Err(Error::Config("cannot remove the last project".into()));
        }
        projects.remove(name);
    }

    let snapshot = state.projects.read().await.clone();
    io::save_config(&snapshot, &state.config_dir).await;

    // If the removed project was active, switch to the first remaining.
    if active == name {
        let first = state.projects.read().await.keys().next().cloned();
        if let Some(new_active) = first {
            *state.active_project.write().await = new_active;
        }
    }

    crate::slog!("[gateway] Removed project '{name}'");
    Ok(())
}

/// Initialise a new huskies project at the given path.
///
/// Optionally registers the project in the gateway's project map.
pub async fn init_project(
    state: &GatewayState,
    path_str: &str,
    name: Option<&str>,
    url: Option<&str>,
) -> Result<Option<String>, Error> {
    let path_str = path_str.trim();
    if path_str.is_empty() {
        return Err(Error::Config("missing required parameter: path".into()));
    }

    let project_path = std::path::Path::new(path_str);

    if io::has_huskies_dir(project_path) {
        return Err(Error::Config(format!(
            "path '{}' is already a huskies project (.huskies/ exists). \
             Use wizard_status to check setup progress.",
            project_path.display()
        )));
    }

    io::ensure_directory(project_path).map_err(Error::Config)?;

    io::scaffold_project(project_path)
        .map_err(|e| Error::Config(format!("scaffold failed: {e}")))?;

    io::init_wizard_state(project_path);

    // Optionally register in projects.toml.
    let registered_name: Option<String> = match (name, url) {
        (Some(n), Some(u)) if !n.trim().is_empty() && !u.trim().is_empty() => {
            let n = n.trim();
            let u = u.trim();
            let mut projects = state.projects.write().await;
            if projects.contains_key(n) {
                return Err(Error::DuplicateToken(format!(
                    "project '{n}' is already registered. Choose a different name or use switch_project."
                )));
            }
            projects.insert(n.to_string(), ProjectEntry { url: u.to_string() });
            io::save_config(&projects, &state.config_dir).await;
            crate::slog!("[gateway] init_project: registered '{n}' ({u})");
            Some(n.to_string())
        }
        _ => None,
    };

    Ok(registered_name)
}

/// Fetch aggregated health status across all projects.
pub async fn health_check_all(state: &GatewayState) -> (bool, BTreeMap<String, &'static str>) {
    let mut all_healthy = true;
    let mut statuses = BTreeMap::new();

    let project_entries: Vec<(String, String)> = state
        .projects
        .read()
        .await
        .iter()
        .map(|(n, e)| (n.clone(), e.url.clone()))
        .collect();

    for (name, url) in &project_entries {
        let healthy = io::check_project_health(&state.client, url)
            .await
            .unwrap_or(false);
        if !healthy {
            all_healthy = false;
        }
        statuses.insert(name.clone(), if healthy { "ok" } else { "error" });
    }

    (all_healthy, statuses)
}

/// Save bot config and restart the bot.
pub async fn save_bot_config_and_restart(state: &GatewayState, content: &str) -> Result<(), Error> {
    io::write_bot_config(&state.config_dir, content).map_err(Error::Config)?;

    // Abort existing bot task and spawn a fresh one.
    {
        let mut handle = state.bot_handle.lock().await;
        if let Some(h) = handle.take() {
            h.abort();
        }
        let gateway_projects: Vec<String> = state.projects.read().await.keys().cloned().collect();
        let gateway_project_urls: BTreeMap<String, String> = state
            .projects
            .read()
            .await
            .iter()
            .map(|(name, entry)| (name.clone(), entry.url.clone()))
            .collect();

        let new_handle = io::spawn_gateway_bot(
            &state.config_dir,
            Arc::clone(&state.active_project),
            gateway_projects,
            gateway_project_urls,
            state.port,
        );
        *handle = new_handle;
    }

    crate::slog!("[gateway] Bot configuration saved; bot restarted");
    Ok(())
}

// ── Tests ────────────────────────────────────────────────────────────────────

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

    fn make_config(names: &[(&str, &str)]) -> GatewayConfig {
        let mut projects = BTreeMap::new();
        for (name, url) in names {
            projects.insert(
                name.to_string(),
                ProjectEntry {
                    url: url.to_string(),
                },
            );
        }
        GatewayConfig { projects }
    }

    #[test]
    fn gateway_state_rejects_empty_config() {
        let config = GatewayConfig {
            projects: BTreeMap::new(),
        };
        assert!(GatewayState::new(config, PathBuf::from("."), 3000).is_err());
    }

    #[test]
    fn gateway_state_sets_first_project_active() {
        let config = make_config(&[("alpha", "http://a:3001"), ("beta", "http://b:3002")]);
        let state = GatewayState::new(config, PathBuf::from("."), 3000).unwrap();
        let active = state.active_project.blocking_read().clone();
        assert_eq!(active, "alpha");
    }

    #[tokio::test]
    async fn switch_project_to_known_project() {
        let config = make_config(&[("alpha", "http://a:3001"), ("beta", "http://b:3002")]);
        let state = GatewayState::new(config, PathBuf::from("."), 3000).unwrap();
        let url = switch_project(&state, "beta").await.unwrap();
        assert_eq!(url, "http://b:3002");
        assert_eq!(*state.active_project.read().await, "beta");
    }

    #[tokio::test]
    async fn switch_project_to_unknown_fails() {
        let config = make_config(&[("alpha", "http://a:3001")]);
        let state = GatewayState::new(config, PathBuf::from("."), 3000).unwrap();
        assert!(switch_project(&state, "nonexistent").await.is_err());
    }

    #[tokio::test]
    async fn switch_project_empty_name_fails() {
        let config = make_config(&[("alpha", "http://a:3001")]);
        let state = GatewayState::new(config, PathBuf::from("."), 3000).unwrap();
        assert!(switch_project(&state, "").await.is_err());
    }

    #[tokio::test]
    async fn active_url_returns_correct_url() {
        let config = make_config(&[("myproj", "http://my:3001")]);
        let state = GatewayState::new(config, PathBuf::from("."), 3000).unwrap();
        let url = state.active_url().await.unwrap();
        assert_eq!(url, "http://my:3001");
    }

    #[test]
    fn error_display_variants() {
        assert!(
            Error::ProjectNotFound("x".into())
                .to_string()
                .contains("Project not found")
        );
        assert!(
            Error::UnreachableProject("x".into())
                .to_string()
                .contains("Unreachable")
        );
        assert!(
            Error::DuplicateToken("x".into())
                .to_string()
                .contains("Duplicate")
        );
        assert!(
            Error::InvalidAgent("x".into())
                .to_string()
                .contains("Invalid agent")
        );
        assert!(
            Error::Config("x".into())
                .to_string()
                .contains("Config error")
        );
        assert!(Error::Upstream("x".into()).to_string().contains("Upstream"));
    }

    #[tokio::test]
    async fn generate_and_register_agent() {
        let config = make_config(&[("test", "http://test:3001")]);
        let state = GatewayState::new(config, PathBuf::new(), 3000).unwrap();
        let token = generate_join_token(&state).await;
        let agent = register_agent(&state, &token, "test-agent".into(), "ws://a".into())
            .await
            .unwrap();
        assert_eq!(agent.label, "test-agent");
        assert!(state.pending_tokens.read().await.is_empty());
        assert_eq!(state.joined_agents.read().await.len(), 1);
    }

    #[tokio::test]
    async fn register_agent_invalid_token_fails() {
        let config = make_config(&[("test", "http://test:3001")]);
        let state = GatewayState::new(config, PathBuf::new(), 3000).unwrap();
        let result = register_agent(&state, "bad-token", "a".into(), "ws://a".into()).await;
        assert!(result.is_err());
    }

    #[tokio::test]
    async fn remove_agent_success() {
        let config = make_config(&[("test", "http://test:3001")]);
        let state = GatewayState::new(config, PathBuf::new(), 3000).unwrap();
        let token = generate_join_token(&state).await;
        let agent = register_agent(&state, &token, "a".into(), "ws://a".into())
            .await
            .unwrap();
        assert!(remove_agent(&state, &agent.id).await);
        assert!(state.joined_agents.read().await.is_empty());
    }

    #[tokio::test]
    async fn heartbeat_agent_updates_timestamp() {
        let config = make_config(&[("test", "http://test:3001")]);
        let state = GatewayState::new(config, PathBuf::new(), 3000).unwrap();
        let token = generate_join_token(&state).await;
        let agent = register_agent(&state, &token, "a".into(), "ws://a".into())
            .await
            .unwrap();
        let old_ts = agent.last_seen;
        // Small sleep to ensure timestamp differs.
        tokio::time::sleep(std::time::Duration::from_millis(10)).await;
        assert!(heartbeat_agent(&state, &agent.id).await);
        let agents = state.joined_agents.read().await;
        assert!(agents[0].last_seen >= old_ts);
    }

    #[tokio::test]
    async fn init_project_scaffolds_directory() {
        let dir = tempfile::tempdir().unwrap();
        let config = make_config(&[("test", "http://test:3001")]);
        let state = GatewayState::new(config, PathBuf::new(), 3000).unwrap();
        let result = init_project(&state, dir.path().to_str().unwrap(), None, None).await;
        assert!(result.is_ok());
        assert!(dir.path().join(".huskies").exists());
    }

    #[tokio::test]
    async fn init_project_already_exists_fails() {
        let dir = tempfile::tempdir().unwrap();
        std::fs::create_dir_all(dir.path().join(".huskies")).unwrap();
        let config = make_config(&[("test", "http://test:3001")]);
        let state = GatewayState::new(config, PathBuf::new(), 3000).unwrap();
        let result = init_project(&state, dir.path().to_str().unwrap(), None, None).await;
        assert!(result.is_err());
    }
}
