//! `CliGenerator` — generates a Unix shell script and/or Windows `.cmd` file.
//!
//! The generated script:
//! - Has a `#!/usr/bin/env sh` shebang on Unix.
//! - Contains a `TOKEN` constant with the session bearer token.
//! - Contains a `BRIDGE` constant with the proxy URL.
//! - Has one `case` branch per upstream tool, each mapped to a kebab-case subcommand.
//! - Passes `Authorization: Bearer $TOKEN` on every `POST /exec` request.
//! - Is marked executable (Unix `chmod 755`).

use std::collections::HashSet;
use std::fs;
use std::path::PathBuf;

use crate::cli::mapping::tool_name_to_subcommand;
use crate::client_gen::generator::{ClientGenerator, GeneratorConfig};
use crate::Error;

pub struct CliGenerator;

impl ClientGenerator for CliGenerator {
    fn generate(&self, config: &GeneratorConfig) -> Result<Vec<PathBuf>, Error> {
        fs::create_dir_all(&config.output_dir)?;
        let path = config.output_dir.join(&config.cli_name);
        let content = render_unix_script(config);
        fs::write(&path, content)?;

        #[cfg(unix)]
        {
            use std::os::unix::fs::PermissionsExt;
            let mut permissions = fs::metadata(&path)?.permissions();
            permissions.set_mode(0o755);
            fs::set_permissions(&path, permissions)?;
        }

        #[cfg(windows)]
        {
            let cmd_path = config.output_dir.join(format!("{}.cmd", config.cli_name));
            fs::write(&cmd_path, render_windows_cmd(config))?;
            return Ok(vec![path, cmd_path]);
        }

        #[cfg(not(windows))]
        Ok(vec![path])
    }
}

fn render_unix_script(config: &GeneratorConfig) -> String {
    let help = render_top_level_help(config);
    let mut script = format!(
        r#"#!/usr/bin/env sh
# generated by mcp-compressor — do not edit manually
BRIDGE='{bridge}'
TOKEN='{token}'
SESSION_PID='{pid}'

show_help() {{
cat <<'HELP'
{help}
HELP
}}

case "$1" in
  ""|--help|-h|help)
    show_help
    ;;
"#,
        bridge = shell_quote(&config.bridge_url),
        token = shell_quote(&config.token),
        pid = config.session_pid,
        help = help,
    );

    for tool in &config.tools {
        let subcommand = tool_name_to_subcommand(&tool.name);
        let tool_help = render_tool_help(&config.cli_name, tool);
        script.push_str(&format!(
            r#"  {subcommand})
    shift
    if [ "$1" = "--help" ] || [ "$1" = "-h" ] || [ "$1" = "help" ]; then
cat <<'HELP'
{tool_help}
HELP
      exit 0
    fi
    python3 - "$BRIDGE" "$TOKEN" "{tool_name}" "$@" <<'PY'
import json, sys, urllib.error, urllib.request
bridge, token, tool_name, *argv = sys.argv[1:]
tool_input = {{}}
index = 0
while index < len(argv):
    flag = argv[index]
    if not flag.startswith("--"):
        raise SystemExit(f"unexpected positional argument: {{flag}}")
    key = flag[2:].replace("-", "_")
    if index + 1 >= len(argv) or argv[index + 1].startswith("--"):
        tool_input[key] = True
        index += 1
    else:
        raw_value = argv[index + 1]
        try:
            tool_input[key] = json.loads(raw_value)
        except json.JSONDecodeError:
            tool_input[key] = raw_value
        index += 2
payload = json.dumps({{"tool": tool_name, "input": tool_input}}).encode()
req = urllib.request.Request(
    bridge + "/exec",
    data=payload,
    headers={{"Content-Type": "application/json", "Authorization": "Bearer " + token}},
    method="POST",
)
try:
    with urllib.request.urlopen(req, timeout=30) as resp:
        sys.stdout.write(resp.read().decode())
except urllib.error.HTTPError as exc:
    message = exc.read().decode(errors="replace") or exc.reason
    print(f"mcp-compressor proxy returned HTTP {{exc.code}}: {{message}}", file=sys.stderr)
    raise SystemExit(1)
except urllib.error.URLError as exc:
    print(
        "mcp-compressor proxy is not running; restart the mcp-compressor CLI-mode process and try again.",
        file=sys.stderr,
    )
    print(f"details: {{exc.reason}}", file=sys.stderr)
    raise SystemExit(1)
PY
    ;;
"#,
            subcommand = subcommand,
            tool_help = tool_help,
            tool_name = shell_quote(&tool.name),
        ));
    }

    script.push_str(
        r#"  *)
    echo "Usage: $0 <subcommand> [args...]" >&2
    exit 2
    ;;
esac
"#,
    );
    script
}

#[cfg(windows)]
fn render_windows_cmd(config: &GeneratorConfig) -> String {
    let mut script = format!(
        "@echo off\r\nREM generated by mcp-compressor -- do not edit manually\r\nset BRIDGE={}\r\nset TOKEN={}\r\n",
        config.bridge_url, config.token
    );
    for tool in &config.tools {
        script.push_str(&format!(
            "REM subcommand: {}\r\n",
            tool_name_to_subcommand(&tool.name)
        ));
    }
    script
}

fn render_top_level_help(config: &GeneratorConfig) -> String {
    let mut help = format!(
        "{name} - the {name} toolset\n\nWhen relevant, outputs from this CLI will prefer using the TOON format for more efficient representation of data.\n\nUSAGE:\n  {name} <subcommand> [options]\n\nSUBCOMMANDS:\n",
        name = config.cli_name,
    );
    for tool in &config.tools {
        help.push_str(&format!(
            "  {:<35} {}\n",
            tool_name_to_subcommand(&tool.name),
            first_line(tool.description.as_deref().unwrap_or("")),
        ));
    }
    help.push_str(&format!(
        "\nRun '{} <subcommand> --help' for subcommand usage.",
        config.cli_name
    ));
    help
}

fn render_tool_help(cli_name: &str, tool: &crate::compression::engine::Tool) -> String {
    let subcommand = tool_name_to_subcommand(&tool.name);
    let mut help = format!(
        "{cli_name} {subcommand}\n\n{}\n\nUSAGE:\n  {cli_name} {subcommand}",
        tool.description.as_deref().unwrap_or("Invoke this tool."),
    );
    for param in tool.param_names() {
        help.push_str(&format!(" --{} <value>", tool_name_to_subcommand(&param)));
    }
    help.push_str("\n");
    let options = tool_options(tool);
    if !options.is_empty() {
        help.push_str("\nOPTIONS:\n");
        for option in options {
            help.push_str(&format_tool_option_help(&option));
        }
    }
    help
}

#[derive(Debug, Clone, PartialEq, Eq)]
struct ToolOption {
    name: String,
    ty: String,
    required: bool,
    description: Option<String>,
    default: Option<String>,
    enum_values: Vec<String>,
}

fn tool_options(tool: &crate::compression::engine::Tool) -> Vec<ToolOption> {
    let required = tool
        .input_schema
        .get("required")
        .and_then(|value| value.as_array())
        .map(|values| {
            values
                .iter()
                .filter_map(|value| value.as_str().map(str::to_string))
                .collect::<HashSet<_>>()
        })
        .unwrap_or_default();

    tool.input_schema
        .get("properties")
        .and_then(|value| value.as_object())
        .map(|properties| {
            properties
                .iter()
                .map(|(name, schema)| ToolOption {
                    name: name.clone(),
                    ty: schema_type_label(schema),
                    required: required.contains(name),
                    description: schema
                        .get("description")
                        .and_then(|value| value.as_str())
                        .map(str::to_string),
                    default: schema.get("default").map(default_value_label),
                    enum_values: schema
                        .get("enum")
                        .and_then(|value| value.as_array())
                        .map(|values| values.iter().map(default_value_label).collect())
                        .unwrap_or_default(),
                })
                .collect()
        })
        .unwrap_or_default()
}

fn format_tool_option_help(option: &ToolOption) -> String {
    let flag = format!("--{}", tool_name_to_subcommand(&option.name));
    let requirement = if option.required {
        "required"
    } else {
        "optional"
    };
    let mut line = format!("  {flag:<28} <{}>  {requirement}", option.ty);
    let mut details = Vec::new();
    if let Some(description) = &option.description {
        details.push(first_line(description).to_string());
    }
    if !option.enum_values.is_empty() {
        details.push(format!("values: {}", option.enum_values.join(", ")));
    }
    if let Some(default) = &option.default {
        details.push(format!("default: {default}"));
    }
    if !details.is_empty() {
        line.push_str(" — ");
        line.push_str(&details.join("; "));
    }
    line.push('\n');
    line
}

fn schema_type_label(schema: &serde_json::Value) -> String {
    if let Some(values) = schema.get("enum").and_then(|value| value.as_array()) {
        let labels = values
            .iter()
            .filter_map(|value| value.as_str().map(str::to_string))
            .collect::<Vec<_>>();
        if !labels.is_empty() {
            return labels.join("|");
        }
    }
    match schema.get("type").and_then(|value| value.as_str()) {
        Some("integer") => "integer".to_string(),
        Some("number") => "number".to_string(),
        Some("boolean") => "boolean".to_string(),
        Some("array") => schema
            .get("items")
            .map(|items| format!("{}[]", schema_type_label(items)))
            .unwrap_or_else(|| "array".to_string()),
        Some("object") => "json".to_string(),
        Some("string") | None => "string".to_string(),
        Some(other) => other.to_string(),
    }
}

fn default_value_label(value: &serde_json::Value) -> String {
    match value {
        serde_json::Value::String(value) => value.clone(),
        other => other.to_string(),
    }
}

fn first_line(value: &str) -> &str {
    value.lines().next().unwrap_or("")
}

fn shell_quote(value: &str) -> String {
    value.replace('\'', "'\\''")
}

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

#[cfg(test)]
mod tests {
    use super::*;
    use crate::client_gen::generator::test_helpers::{make_config, make_config_multiword_tool};
    use crate::client_gen::ClientGenerator;
    use std::fs;

    // ------------------------------------------------------------------
    // File creation
    // ------------------------------------------------------------------

    /// generate() returns at least one path.
    #[test]
    fn generate_returns_non_empty_paths() {
        let dir = tempfile::tempdir().unwrap();
        let config = make_config(dir.path());
        let paths = CliGenerator.generate(&config).unwrap();
        assert!(
            !paths.is_empty(),
            "expected at least one generated artifact"
        );
    }

    /// All returned paths actually exist on disk.
    #[test]
    fn generated_paths_exist() {
        let dir = tempfile::tempdir().unwrap();
        let config = make_config(dir.path());
        let paths = CliGenerator.generate(&config).unwrap();
        for path in &paths {
            assert!(path.exists(), "path does not exist: {path:?}");
        }
    }

    /// All returned paths are inside the configured `output_dir`.
    #[test]
    fn generated_paths_inside_output_dir() {
        let dir = tempfile::tempdir().unwrap();
        let config = make_config(dir.path());
        let paths = CliGenerator.generate(&config).unwrap();
        for path in &paths {
            assert!(
                path.starts_with(dir.path()),
                "path {path:?} is outside output_dir {:?}",
                dir.path(),
            );
        }
    }

    // ------------------------------------------------------------------
    // Unix script content
    // ------------------------------------------------------------------

    /// On Unix the primary script has a shebang line.
    #[test]
    #[cfg(unix)]
    fn unix_script_has_shebang() {
        let dir = tempfile::tempdir().unwrap();
        let config = make_config(dir.path());
        let paths = CliGenerator.generate(&config).unwrap();
        let unix_script = paths.iter().find(|p| p.extension().is_none()).unwrap();
        let content = fs::read_to_string(unix_script).unwrap();
        assert!(
            content.starts_with("#!"),
            "script must start with shebang, got: {content:?}"
        );
    }

    /// On Unix the primary script is named after `cli_name`.
    #[test]
    #[cfg(unix)]
    fn unix_script_named_after_cli_name() {
        use std::ffi::OsStr;
        let dir = tempfile::tempdir().unwrap();
        let config = make_config(dir.path());
        let paths = CliGenerator.generate(&config).unwrap();
        // The Unix script has no extension
        let unix_script = paths.iter().find(|p| p.extension().is_none()).unwrap();
        assert_eq!(unix_script.file_name(), Some(OsStr::new("my-server")));
    }

    /// The script embeds the session token verbatim.
    #[test]
    #[cfg(unix)]
    fn unix_script_contains_token() {
        let dir = tempfile::tempdir().unwrap();
        let config = make_config(dir.path());
        let paths = CliGenerator.generate(&config).unwrap();
        let unix_script = paths.iter().find(|p| p.extension().is_none()).unwrap();
        let content = fs::read_to_string(unix_script).unwrap();
        assert!(content.contains(&config.token), "token not found in script");
    }

    /// The script embeds the bridge URL.
    #[test]
    #[cfg(unix)]
    fn unix_script_contains_bridge_url() {
        let dir = tempfile::tempdir().unwrap();
        let config = make_config(dir.path());
        let paths = CliGenerator.generate(&config).unwrap();
        let unix_script = paths.iter().find(|p| p.extension().is_none()).unwrap();
        let content = fs::read_to_string(unix_script).unwrap();
        assert!(
            content.contains(&config.bridge_url),
            "bridge URL not found in script"
        );
    }

    /// Each upstream tool name appears as a subcommand in the script.
    #[test]
    #[cfg(unix)]
    fn unix_script_contains_all_tool_subcommands() {
        let dir = tempfile::tempdir().unwrap();
        let config = make_config(dir.path());
        let paths = CliGenerator.generate(&config).unwrap();
        let unix_script = paths.iter().find(|p| p.extension().is_none()).unwrap();
        let content = fs::read_to_string(unix_script).unwrap();
        // Tool names appear either as-is or in kebab-case
        assert!(content.contains("fetch"), "subcommand 'fetch' not found");
        assert!(content.contains("search"), "subcommand 'search' not found");
    }

    /// A multi-word tool name appears as its kebab-case subcommand.
    #[test]
    #[cfg(unix)]
    fn unix_script_kebab_case_subcommand() {
        let dir = tempfile::tempdir().unwrap();
        let config = make_config_multiword_tool(dir.path());
        let paths = CliGenerator.generate(&config).unwrap();
        let unix_script = paths.iter().find(|p| p.extension().is_none()).unwrap();
        let content = fs::read_to_string(unix_script).unwrap();
        // "get_confluence_page" → "get-confluence-page"
        assert!(
            content.contains("get-confluence-page"),
            "expected kebab-case subcommand in script",
        );
    }

    /// Top-level help includes legacy-style title, usage, and subcommands.
    #[test]
    #[cfg(unix)]
    fn unix_script_contains_top_level_help() {
        let dir = tempfile::tempdir().unwrap();
        let config = make_config(dir.path());
        let paths = CliGenerator.generate(&config).unwrap();
        let unix_script = paths.iter().find(|p| p.extension().is_none()).unwrap();
        let content = fs::read_to_string(unix_script).unwrap();
        assert!(content.contains("my-server - the my-server toolset"));
        assert!(content.contains("USAGE:"));
        assert!(content.contains("SUBCOMMANDS:"));
        assert!(content.contains("Run 'my-server <subcommand> --help'"));
    }

    /// Subcommand help includes usage and option names.
    #[test]
    #[cfg(unix)]
    fn unix_script_contains_subcommand_help() {
        let dir = tempfile::tempdir().unwrap();
        let config = make_config(dir.path());
        let paths = CliGenerator.generate(&config).unwrap();
        let unix_script = paths.iter().find(|p| p.extension().is_none()).unwrap();
        let content = fs::read_to_string(unix_script).unwrap();
        assert!(content.contains("my-server fetch"));
        assert!(content.contains("OPTIONS:"));
        assert!(content.contains("--url <value>"));
        assert!(content.contains("--url"));
        assert!(content.contains("<string>  required — URL to fetch."));
        assert!(content.contains("--timeout"));
        assert!(content.contains("<integer>  optional — Timeout in seconds.; default: 30"));
        assert!(content.contains("--method"));
        assert!(content.contains("values: GET, POST"));
    }

    /// On Unix the generated script is executable.
    #[test]
    #[cfg(unix)]
    fn unix_script_is_executable() {
        use std::os::unix::fs::PermissionsExt;
        let dir = tempfile::tempdir().unwrap();
        let config = make_config(dir.path());
        let paths = CliGenerator.generate(&config).unwrap();
        let unix_script = paths.iter().find(|p| p.extension().is_none()).unwrap();
        let perms = fs::metadata(unix_script).unwrap().permissions();
        assert_ne!(perms.mode() & 0o111, 0, "script must be executable");
    }

    // ------------------------------------------------------------------
    // Windows .cmd content
    // ------------------------------------------------------------------

    /// On Windows a `.cmd` file is generated.
    #[test]
    #[cfg(windows)]
    fn windows_cmd_generated() {
        use std::ffi::OsStr;
        let dir = tempfile::tempdir().unwrap();
        let config = make_config(dir.path());
        let paths = CliGenerator.generate(&config).unwrap();
        assert!(
            paths
                .iter()
                .any(|p| p.extension() == Some(OsStr::new("cmd"))),
            "expected a .cmd file on Windows",
        );
    }

    /// The Windows `.cmd` file contains the session token.
    #[test]
    #[cfg(windows)]
    fn windows_cmd_contains_token() {
        use std::ffi::OsStr;
        let dir = tempfile::tempdir().unwrap();
        let config = make_config(dir.path());
        let paths = CliGenerator.generate(&config).unwrap();
        let cmd = paths
            .iter()
            .find(|p| p.extension() == Some(OsStr::new("cmd")))
            .unwrap();
        let content = fs::read_to_string(cmd).unwrap();
        assert!(content.contains(&config.token));
    }
}
