//! Gateway configuration types — pure parsing and validation.
//!
//! Contains `ProjectEntry`, `GatewayConfig`, and validation logic.
//! All filesystem I/O (loading from disk) lives in `io.rs`.

use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;

/// A single project entry in `projects.toml`.
///
/// Phase 2 (story 899): `url` is now optional — a project served exclusively
/// via the sled-uplink WebSocket does not need an HTTP base URL.  The `url`
/// field is deprecated for removal in a future release; configure
/// `auth_token` instead and rely on the WS uplink for all traffic.
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct ProjectEntry {
    /// Base URL of the project's huskies container (e.g. `http://localhost:3001`).
    ///
    /// **Deprecated** (story 899) — when a sled connects via the uplink WS the
    /// gateway routes all MCP traffic over that connection instead.  The URL is
    /// used as a fallback when no live uplink exists.  Omit for WS-only projects.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub url: Option<String>,
    /// Shared-secret token used to authenticate this project's sled when it
    /// connects to `/api/sled-uplink`.  Takes precedence over the top-level
    /// `[sled_tokens]` table for projects that set this field.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub auth_token: Option<String>,
    /// Host-local port for SSH access into the project container.
    ///
    /// Set by `new project` (story 1108). The container's SSH server is bound
    /// to `127.0.0.1:<ssh_port>:22` so the user can connect with
    /// `ssh huskies@127.0.0.1 -p <ssh_port> -i ~/.huskies/<name>/id_ed25519`.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub ssh_port: Option<u16>,
    /// Absolute host path of the project directory (workspace bind-mount source).
    ///
    /// Set by `new project` (story 1114). When `--path` is supplied the value
    /// differs from the default `~/huskies/<name>/`.  Stored here so that later
    /// commands can route to the correct directory without re-deriving it.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub host_path: Option<String>,
    /// Ed25519 node ID (hex pubkey) this sled is expected to answer as.
    ///
    /// Captured automatically from the first successful `/identity` probe
    /// (story 1173) when absent, then checked on every subsequent upgrade and
    /// health probe so a container swapped out from under the gateway is
    /// detected even when its `/health` endpoint still reports ok.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub expected_node_id: Option<String>,
}

impl ProjectEntry {
    /// Convenience constructor for entries that only have a URL (e.g. in tests
    /// and existing `projects.toml` files that have not yet been migrated to
    /// the WS-uplink model).
    pub fn with_url(url: impl Into<String>) -> Self {
        Self {
            url: Some(url.into()),
            auth_token: None,
            ssh_port: None,
            host_path: None,
            expected_node_id: None,
        }
    }

    /// Returns `true` if this entry has a configured HTTP base URL.
    pub fn has_url(&self) -> bool {
        self.url.as_ref().is_some_and(|u| !u.is_empty())
    }
}

/// Configuration for one signed release channel the gateway can `pull` from.
///
/// All three fields are optional in the TOML shape — a channel can be
/// partially configured while it's being set up — but `pull <channel>`
/// (story 1169) requires both `base_url` and `pubkey` to be present. There is
/// no unsigned-pull mode: a missing pubkey is always a hard error, never a
/// fallback to trusting whatever the channel serves.
#[derive(Debug, Clone, Default, PartialEq, Deserialize, Serialize)]
pub struct ReleaseChannelConfig {
    /// Base URL the channel is served from (e.g. `https://releases.example.com/stable`).
    ///
    /// `pull <channel>` fetches `{base_url}/manifest.json` and, once verified,
    /// `{base_url}/{manifest.artifact}`.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub base_url: Option<String>,
    /// Pinned Ed25519 public key (hex) the channel's `manifest.json` signature
    /// must verify against. Generated by `release-tool keygen` and never
    /// derived automatically — an operator must paste it in explicitly.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub pubkey: Option<String>,
    /// Optional bearer token sent as `Authorization: Bearer <token>` on both
    /// the manifest and artifact requests (e.g. for a private release channel).
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub bearer_token: Option<String>,
}

/// Top-level `projects.toml` config.
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
pub struct GatewayConfig {
    /// Map of project name → container configuration.
    #[serde(default)]
    pub projects: BTreeMap<String, ProjectEntry>,
    /// Map of sled_id → shared secret token for sled-uplink authentication.
    ///
    /// **Deprecated** (story 899) — move tokens into per-project
    /// `auth_token` fields instead.  The gateway still honours entries here for
    /// one release to provide a smooth migration window.
    ///
    /// Each entry allows a sled identified by `sled_id` to connect to
    /// `/api/sled-uplink` using the given secret token as a bearer credential.
    #[serde(default)]
    pub sled_tokens: BTreeMap<String, String>,
    /// Map of channel name → signed release channel configuration (story 1169).
    ///
    /// Populated by an operator adding `[release_channels.<name>]` sections to
    /// `projects.toml`. Read by the `pull <channel>` gateway chat command.
    #[serde(default)]
    pub release_channels: BTreeMap<String, ReleaseChannelConfig>,
}

/// Validate that a gateway config has at least one project.
///
/// Returns the name of the first project (alphabetically) on success,
/// or an error message if the config is empty.
pub fn validate_config(config: &GatewayConfig) -> Result<String, String> {
    if config.projects.is_empty() {
        return Err("projects.toml must define at least one project".to_string());
    }
    Ok(config.projects.keys().next().unwrap().clone())
}

/// Validate that a project name exists in the given project map.
///
/// Returns the project's URL (may be empty for WS-uplink-only projects) on
/// success.
pub fn validate_project_exists(
    projects: &BTreeMap<String, ProjectEntry>,
    name: &str,
) -> Result<String, String> {
    projects
        .get(name)
        .map(|p| p.url.clone().unwrap_or_default())
        .ok_or_else(|| {
            let available: Vec<&str> = projects.keys().map(|s| s.as_str()).collect();
            format!(
                "unknown project '{name}'. Available: {}",
                available.join(", ")
            )
        })
}

/// Escape a string as a TOML quoted string.
pub fn toml_string(s: &str) -> String {
    format!("\"{}\"", s.replace('\\', "\\\\").replace('"', "\\\""))
}

/// Serialize a `bot.toml` content string from the given fields.
pub fn serialize_bot_config(
    transport: &str,
    homeserver: Option<&str>,
    username: Option<&str>,
    password: Option<&str>,
    slack_bot_token: Option<&str>,
    slack_signing_secret: Option<&str>,
) -> String {
    match transport {
        "slack" => {
            format!(
                "enabled = true\ntransport = \"slack\"\n\nslack_bot_token = {}\nslack_signing_secret = {}\nslack_channel_ids = []\n",
                toml_string(slack_bot_token.unwrap_or("")),
                toml_string(slack_signing_secret.unwrap_or("")),
            )
        }
        _ => {
            format!(
                "enabled = true\ntransport = \"matrix\"\n\nhomeserver = {}\nusername = {}\npassword = {}\nroom_ids = []\nallowed_users = []\n",
                toml_string(homeserver.unwrap_or("")),
                toml_string(username.unwrap_or("")),
                toml_string(password.unwrap_or("")),
            )
        }
    }
}

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

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

    #[test]
    fn parse_valid_projects_toml() {
        let toml_str = r#"
[projects.huskies]
url = "http://localhost:3001"

[projects.robot-studio]
url = "http://localhost:3002"
"#;
        let config: GatewayConfig = toml::from_str(toml_str).unwrap();
        assert_eq!(config.projects.len(), 2);
        assert_eq!(
            config.projects["huskies"].url.as_deref(),
            Some("http://localhost:3001")
        );
        assert_eq!(
            config.projects["robot-studio"].url.as_deref(),
            Some("http://localhost:3002")
        );
    }

    #[test]
    fn parse_project_without_url_is_valid() {
        let toml_str = r#"
[projects.ws-only]
auth_token = "secret"
"#;
        let config: GatewayConfig = toml::from_str(toml_str).unwrap();
        assert_eq!(config.projects.len(), 1);
        assert!(config.projects["ws-only"].url.is_none());
        assert_eq!(
            config.projects["ws-only"].auth_token.as_deref(),
            Some("secret")
        );
    }

    #[test]
    fn parse_empty_projects_toml() {
        let toml_str = "[projects]\n";
        let config: GatewayConfig = toml::from_str(toml_str).unwrap();
        assert!(config.projects.is_empty());
    }

    #[test]
    fn validate_config_rejects_empty() {
        let config = GatewayConfig {
            projects: BTreeMap::new(),
            sled_tokens: BTreeMap::new(),
            release_channels: BTreeMap::new(),
        };
        assert!(validate_config(&config).is_err());
    }

    #[test]
    fn validate_config_returns_first_project_name() {
        let mut projects = BTreeMap::new();
        projects.insert("beta".into(), ProjectEntry::with_url("http://b"));
        projects.insert("alpha".into(), ProjectEntry::with_url("http://a"));
        let config = GatewayConfig {
            projects,
            sled_tokens: BTreeMap::new(),
            release_channels: BTreeMap::new(),
        };
        assert_eq!(validate_config(&config).unwrap(), "alpha");
    }

    #[test]
    fn validate_config_accepts_ws_only_project() {
        let mut projects = BTreeMap::new();
        projects.insert(
            "ws-only".into(),
            ProjectEntry {
                url: None,
                auth_token: Some("secret".into()),
                ssh_port: None,
                host_path: None,
                expected_node_id: None,
            },
        );
        let config = GatewayConfig {
            projects,
            sled_tokens: BTreeMap::new(),
            release_channels: BTreeMap::new(),
        };
        assert!(validate_config(&config).is_ok());
    }

    #[test]
    fn validate_project_exists_succeeds() {
        let mut projects = BTreeMap::new();
        projects.insert("p1".into(), ProjectEntry::with_url("http://p1"));
        assert_eq!(
            validate_project_exists(&projects, "p1").unwrap(),
            "http://p1"
        );
    }

    #[test]
    fn validate_project_exists_fails() {
        let projects = BTreeMap::new();
        assert!(validate_project_exists(&projects, "missing").is_err());
    }

    #[test]
    fn validate_project_exists_ws_only_returns_empty_url() {
        let mut projects = BTreeMap::new();
        projects.insert(
            "ws".into(),
            ProjectEntry {
                url: None,
                auth_token: Some("tok".into()),
                ssh_port: None,
                host_path: None,
                expected_node_id: None,
            },
        );
        assert_eq!(validate_project_exists(&projects, "ws").unwrap(), "");
    }

    #[test]
    fn project_entry_with_url_constructor() {
        let e = ProjectEntry::with_url("http://example.com");
        assert_eq!(e.url.as_deref(), Some("http://example.com"));
        assert!(e.auth_token.is_none());
        assert!(e.has_url());
    }

    #[test]
    fn project_entry_has_url_false_when_none() {
        let e = ProjectEntry {
            url: None,
            auth_token: Some("tok".into()),
            ssh_port: None,
            host_path: None,
            expected_node_id: None,
        };
        assert!(!e.has_url());
    }

    #[test]
    fn toml_string_escapes_quotes() {
        assert_eq!(toml_string(r#"a"b"#), r#""a\"b""#);
    }

    #[test]
    fn toml_string_escapes_backslashes() {
        assert_eq!(toml_string(r"a\b"), r#""a\\b""#);
    }

    #[test]
    fn serialize_bot_config_matrix() {
        let content = serialize_bot_config(
            "matrix",
            Some("https://mx.io"),
            Some("@bot:mx.io"),
            Some("pass"),
            None,
            None,
        );
        assert!(content.contains("transport = \"matrix\""));
        assert!(content.contains("homeserver = \"https://mx.io\""));
    }

    #[test]
    fn serialize_bot_config_slack() {
        let content =
            serialize_bot_config("slack", None, None, None, Some("xoxb-123"), Some("secret"));
        assert!(content.contains("transport = \"slack\""));
        assert!(content.contains("slack_bot_token = \"xoxb-123\""));
    }

    #[test]
    fn roundtrip_project_entry_with_auth_token() {
        let entry = ProjectEntry {
            url: Some("http://a:3001".into()),
            auth_token: Some("mysecret".into()),
            ssh_port: None,
            host_path: None,
            expected_node_id: None,
        };
        let mut projects = BTreeMap::new();
        projects.insert("myproj".into(), entry);
        let config = GatewayConfig {
            projects,
            sled_tokens: BTreeMap::new(),
            release_channels: BTreeMap::new(),
        };
        let toml_str = toml::to_string_pretty(&config).unwrap();
        let parsed: GatewayConfig = toml::from_str(&toml_str).unwrap();
        assert_eq!(
            parsed.projects["myproj"].url.as_deref(),
            Some("http://a:3001")
        );
        assert_eq!(
            parsed.projects["myproj"].auth_token.as_deref(),
            Some("mysecret")
        );
    }

    #[test]
    fn roundtrip_project_entry_with_ssh_port() {
        let entry = ProjectEntry {
            url: Some("http://127.0.0.1:3101".into()),
            auth_token: None,
            ssh_port: Some(2201),
            host_path: None,
            expected_node_id: None,
        };
        let mut projects = BTreeMap::new();
        projects.insert("myproj".into(), entry);
        let config = GatewayConfig {
            projects,
            sled_tokens: BTreeMap::new(),
            release_channels: BTreeMap::new(),
        };
        let toml_str = toml::to_string_pretty(&config).unwrap();
        let parsed: GatewayConfig = toml::from_str(&toml_str).unwrap();
        assert_eq!(parsed.projects["myproj"].ssh_port, Some(2201));
        // ssh_port must appear in the serialised TOML.
        assert!(
            toml_str.contains("ssh_port = 2201"),
            "ssh_port missing from TOML: {toml_str}"
        );
    }

    #[test]
    fn expected_node_id_roundtrips_and_is_omitted_when_none() {
        let with_id = ProjectEntry {
            url: Some("http://127.0.0.1:3101".into()),
            auth_token: None,
            ssh_port: None,
            host_path: None,
            expected_node_id: Some("ab".repeat(32)),
        };
        let mut projects = BTreeMap::new();
        projects.insert("p".into(), with_id);
        let config = GatewayConfig {
            projects,
            sled_tokens: BTreeMap::new(),
            release_channels: BTreeMap::new(),
        };
        let toml_str = toml::to_string_pretty(&config).unwrap();
        assert!(toml_str.contains("expected_node_id"));
        let parsed: GatewayConfig = toml::from_str(&toml_str).unwrap();
        assert_eq!(
            parsed.projects["p"].expected_node_id.as_deref(),
            Some("ab".repeat(32).as_str())
        );

        let without_id = ProjectEntry::with_url("http://127.0.0.1:3101");
        let mut projects2 = BTreeMap::new();
        projects2.insert("p".into(), without_id);
        let config2 = GatewayConfig {
            projects: projects2,
            sled_tokens: BTreeMap::new(),
            release_channels: BTreeMap::new(),
        };
        let toml_str2 = toml::to_string_pretty(&config2).unwrap();
        assert!(
            !toml_str2.contains("expected_node_id"),
            "expected_node_id should be omitted when None: {toml_str2}"
        );
    }

    #[test]
    fn ssh_port_none_is_omitted_from_toml() {
        let entry = ProjectEntry::with_url("http://127.0.0.1:3101");
        let mut projects = BTreeMap::new();
        projects.insert("p".into(), entry);
        let config = GatewayConfig {
            projects,
            sled_tokens: BTreeMap::new(),
            release_channels: BTreeMap::new(),
        };
        let toml_str = toml::to_string_pretty(&config).unwrap();
        assert!(
            !toml_str.contains("ssh_port"),
            "ssh_port should be omitted when None: {toml_str}"
        );
    }
}
