//! `TypeScriptGenerator` — generates a typed ESM module (`.ts` + `.d.ts`).
//!
//! The generated module:
//!
//! - Is named `<cli_name>.ts` (source) and `<cli_name>.d.ts` (declarations).
//! - Contains `BRIDGE` and `TOKEN` constants.
//! - Exports one `async function <camelCaseName>(...)` per upstream tool with:
//!   - Required params positional; optional params with `?` suffix and `undefined` default.
//!   - Return type `Promise<string>`.
//! - Uses the Fetch API (`fetch`) for HTTP calls — no additional dependencies.
//! - Contains a `// generated by mcp-compressor — do not edit manually` header.
//!
//! Tool name convention: `snake_case → camelCase`
//! e.g. `get_confluence_page → getConfluencePage`.

use crate::client_gen::generator::{ClientGenerator, GeneratedArtifact, GeneratorConfig};
use crate::Error;

pub struct TypeScriptGenerator;

impl ClientGenerator for TypeScriptGenerator {
    fn render(&self, config: &GeneratorConfig) -> Result<Vec<GeneratedArtifact>, Error> {
        Ok(vec![
            GeneratedArtifact::new(format!("{}.ts", config.cli_name), render_ts_module(config)),
            GeneratedArtifact::new(format!("{}.d.ts", config.cli_name), render_dts(config)),
        ])
    }
}

fn render_ts_module(config: &GeneratorConfig) -> String {
    let mut module = format!(
        r#"// generated by mcp-compressor — do not edit manually
const BRIDGE = {bridge:?};
const TOKEN = {token:?};
const SESSION_PID = {pid};
const HEADERS = {{ Authorization: `Bearer ${{TOKEN}}`, "Content-Type": "application/json" }};

async function execTool(tool: string, input: Record<string, unknown>): Promise<string> {{
  let res: Response;
  try {{
    res = await fetch(`${{BRIDGE}}/exec`, {{
      method: "POST",
      headers: HEADERS,
      body: JSON.stringify({{ tool, input }}),
    }});
  }} catch (error) {{
    throw new Error(`mcp-compressor proxy is not running; restart the mcp-compressor process and try again. details: ${{error instanceof Error ? error.message : String(error)}}`);
  }}
  if (!res.ok) {{
    throw new Error(`mcp-compressor proxy returned HTTP ${{res.status}}: ${{await res.text()}}`);
  }}
  return res.text();
}}
"#,
        bridge = config.bridge_url,
        token = config.token,
        pid = config.session_pid,
    );

    for tool in &config.tools {
        let function_name = snake_to_camel(&tool.name);
        let params = ts_params(tool);
        let input = ts_input_object(tool);
        let doc = ts_doc_comment(tool.description.as_deref().unwrap_or(""));
        module.push_str(&format!(
            r#"
{doc}
export async function {function_name}({params}): Promise<string> {{
  return execTool({tool_name:?}, {input});
}}
"#,
            doc = doc,
            function_name = function_name,
            params = params,
            tool_name = tool.name,
            input = input,
        ));
    }

    module
}

fn render_dts(config: &GeneratorConfig) -> String {
    let mut declarations = "// generated by mcp-compressor — do not edit manually\n".to_string();
    for tool in &config.tools {
        let doc = ts_doc_comment(tool.description.as_deref().unwrap_or(""));
        declarations.push_str(&format!(
            "{doc}\nexport function {name}({params}): Promise<string>;\n",
            doc = doc,
            name = snake_to_camel(&tool.name),
            params = ts_params(tool),
        ));
    }
    declarations
}

fn ts_doc_comment(description: &str) -> String {
    if description.trim().is_empty() {
        return "/** Invoke this tool. */".to_string();
    }
    let lines = description
        .lines()
        .map(|line| format!(" * {}", line.replace("*/", "* /")))
        .collect::<Vec<_>>()
        .join("\n");
    format!("/**\n{lines}\n */")
}

fn snake_to_camel(name: &str) -> String {
    let mut out = String::new();
    let mut uppercase_next = false;
    for ch in name.chars() {
        if ch == '_' || ch == '-' {
            uppercase_next = true;
        } else if uppercase_next {
            out.push(ch.to_ascii_uppercase());
            uppercase_next = false;
        } else {
            out.push(ch);
        }
    }
    out
}

fn ts_params(tool: &crate::compression::engine::Tool) -> String {
    ordered_param_names(tool)
        .into_iter()
        .map(|(name, required)| {
            if required {
                format!("{name}: string")
            } else {
                format!("{name}?: string")
            }
        })
        .collect::<Vec<_>>()
        .join(", ")
}

fn ts_input_object(tool: &crate::compression::engine::Tool) -> String {
    let pairs = tool
        .param_names()
        .into_iter()
        .map(|name| format!("{name}: {name}"))
        .collect::<Vec<_>>()
        .join(", ");
    format!("{{ {pairs} }}")
}

fn ordered_param_names(tool: &crate::compression::engine::Tool) -> Vec<(String, bool)> {
    let required = required_params(tool);
    let mut params = tool
        .param_names()
        .into_iter()
        .map(|name| {
            let is_required = required.contains(&name);
            (name, is_required)
        })
        .collect::<Vec<_>>();
    params.sort_by_key(|(_name, is_required)| !*is_required);
    params
}

fn required_params(tool: &crate::compression::engine::Tool) -> Vec<String> {
    tool.input_schema
        .get("required")
        .and_then(serde_json::Value::as_array)
        .map(|values| {
            values
                .iter()
                .filter_map(serde_json::Value::as_str)
                .map(ToString::to_string)
                .collect()
        })
        .unwrap_or_default()
}

// ---------------------------------------------------------------------------
// 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, GeneratorConfig};
    use crate::compression::engine::Tool;
    use std::ffi::OsStr;
    use std::fs;
    use std::path::PathBuf;

    // Helper: find the `.ts` (non-declaration) path in a list of paths.
    fn find_ts(paths: &[PathBuf]) -> &PathBuf {
        paths
            .iter()
            .find(|p| {
                p.extension() == Some(OsStr::new("ts"))
                    && !p.to_str().unwrap_or("").ends_with(".d.ts")
            })
            .expect("no .ts file found")
    }

    // Helper: find the `.d.ts` declaration path.
    fn find_dts(paths: &[PathBuf]) -> &PathBuf {
        paths
            .iter()
            .find(|p| p.to_str().unwrap_or("").ends_with(".d.ts"))
            .expect("no .d.ts file found")
    }

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

    /// generate() returns exactly two paths: `.ts` and `.d.ts`.
    #[test]
    fn generate_returns_two_files() {
        let dir = tempfile::tempdir().unwrap();
        let config = make_config(dir.path());
        let paths = TypeScriptGenerator.generate(&config).unwrap();
        assert_eq!(
            paths.len(),
            2,
            "expected exactly two generated files (.ts and .d.ts)"
        );
    }

    /// A `.ts` source file is produced.
    #[test]
    fn generates_ts_source_file() {
        let dir = tempfile::tempdir().unwrap();
        let config = make_config(dir.path());
        let paths = TypeScriptGenerator.generate(&config).unwrap();
        let _ = find_ts(&paths); // panics if not found
    }

    /// A `.d.ts` declaration file is produced.
    #[test]
    fn generates_dts_declaration_file() {
        let dir = tempfile::tempdir().unwrap();
        let config = make_config(dir.path());
        let paths = TypeScriptGenerator.generate(&config).unwrap();
        let _ = find_dts(&paths); // panics if not found
    }

    /// The `.ts` file is named `<cli_name>.ts`.
    #[test]
    fn ts_file_named_after_cli_name() {
        let dir = tempfile::tempdir().unwrap();
        let config = make_config(dir.path());
        let paths = TypeScriptGenerator.generate(&config).unwrap();
        let ts = find_ts(&paths);
        assert_eq!(ts.file_name(), Some(OsStr::new("my-server.ts")));
    }

    /// The `.d.ts` file is named `<cli_name>.d.ts`.
    #[test]
    fn dts_file_named_after_cli_name() {
        let dir = tempfile::tempdir().unwrap();
        let config = make_config(dir.path());
        let paths = TypeScriptGenerator.generate(&config).unwrap();
        let dts = find_dts(&paths);
        assert_eq!(dts.file_name(), Some(OsStr::new("my-server.d.ts")));
    }

    /// Both generated files actually exist on disk.
    #[test]
    fn all_files_exist() {
        let dir = tempfile::tempdir().unwrap();
        let config = make_config(dir.path());
        let paths = TypeScriptGenerator.generate(&config).unwrap();
        for p in &paths {
            assert!(p.exists(), "expected file to exist: {p:?}");
        }
    }

    // ------------------------------------------------------------------
    // .ts content — header
    // ------------------------------------------------------------------

    /// The `.ts` file starts with the "do not edit manually" comment.
    #[test]
    fn ts_has_do_not_edit_header() {
        let dir = tempfile::tempdir().unwrap();
        let config = make_config(dir.path());
        let paths = TypeScriptGenerator.generate(&config).unwrap();
        let ts = find_ts(&paths);
        let content = fs::read_to_string(ts).unwrap();
        assert!(
            content.contains("do not edit"),
            "expected 'do not edit' header"
        );
    }

    // ------------------------------------------------------------------
    // .ts content — constants
    // ------------------------------------------------------------------

    /// The `.ts` file contains the session `TOKEN` constant.
    #[test]
    fn ts_contains_token_constant() {
        let dir = tempfile::tempdir().unwrap();
        let config = make_config(dir.path());
        let paths = TypeScriptGenerator.generate(&config).unwrap();
        let ts = find_ts(&paths);
        let content = fs::read_to_string(ts).unwrap();
        assert!(content.contains(&config.token), "token not found");
        assert!(content.contains("TOKEN"), "TOKEN constant not found");
    }

    /// The `.ts` file contains the `BRIDGE` URL constant.
    #[test]
    fn ts_contains_bridge_constant() {
        let dir = tempfile::tempdir().unwrap();
        let config = make_config(dir.path());
        let paths = TypeScriptGenerator.generate(&config).unwrap();
        let ts = find_ts(&paths);
        let content = fs::read_to_string(ts).unwrap();
        assert!(content.contains(&config.bridge_url), "bridge URL not found");
        assert!(content.contains("BRIDGE"), "BRIDGE constant not found");
    }

    // ------------------------------------------------------------------
    // .ts content — function exports
    // ------------------------------------------------------------------

    /// Each upstream tool produces an `export async function <name>` entry.
    #[test]
    fn ts_exports_async_function_per_tool() {
        let dir = tempfile::tempdir().unwrap();
        let config = make_config(dir.path());
        let paths = TypeScriptGenerator.generate(&config).unwrap();
        let ts = find_ts(&paths);
        let content = fs::read_to_string(ts).unwrap();
        assert!(
            content.contains("export async function fetch("),
            "'fetch' function not found"
        );
        assert!(
            content.contains("export async function search("),
            "'search' function not found"
        );
    }

    /// Functions return `Promise<string>`.
    #[test]
    fn ts_functions_return_promise_string() {
        let dir = tempfile::tempdir().unwrap();
        let config = make_config(dir.path());
        let paths = TypeScriptGenerator.generate(&config).unwrap();
        let ts = find_ts(&paths);
        let content = fs::read_to_string(ts).unwrap();
        assert!(
            content.contains("Promise<string>"),
            "Promise<string> return type not found"
        );
    }

    #[test]
    fn required_params_are_ordered_before_optional_params() {
        let dir = tempfile::tempdir().unwrap();
        let config = GeneratorConfig {
            cli_name: "real".to_string(),
            bridge_url: "http://127.0.0.1:1".to_string(),
            token: "token".to_string(),
            tools: vec![Tool::new(
                "real_tool",
                Some("Real schema".to_string()),
                serde_json::json!({
                    "type": "object",
                    "properties": {
                        "optional_first": {"type": "string"},
                        "required_second": {"type": "string"},
                        "optional_third": {"type": "string"}
                    },
                    "required": ["required_second"]
                }),
            )],
            session_pid: 1,
            output_dir: dir.path().to_path_buf(),
            extra_cli_bridges: Vec::new(),
        };
        let paths = TypeScriptGenerator.generate(&config).unwrap();
        let ts = find_ts(&paths);
        let dts = find_dts(&paths);
        let content = fs::read_to_string(ts).unwrap();
        let declarations = fs::read_to_string(dts).unwrap();
        assert!(content.contains("export async function realTool(required_second: string, optional_first?: string, optional_third?: string): Promise<string>"));
        assert!(content.contains("{ optional_first: optional_first, required_second: required_second, optional_third: optional_third }"));
        assert!(declarations.contains("export function realTool(required_second: string, optional_first?: string, optional_third?: string): Promise<string>;"));
    }

    /// A multi-word snake_case tool name is exported as camelCase.
    #[test]
    fn ts_multiword_tool_name_is_camel_case() {
        let dir = tempfile::tempdir().unwrap();
        let config = make_config_multiword_tool(dir.path());
        let paths = TypeScriptGenerator.generate(&config).unwrap();
        let ts = find_ts(&paths);
        let content = fs::read_to_string(ts).unwrap();
        // "get_confluence_page" → "getConfluencePage"
        assert!(
            content.contains("getConfluencePage"),
            "expected camelCase function name 'getConfluencePage', got: {content:?}",
        );
    }

    // ------------------------------------------------------------------
    // .d.ts content
    // ------------------------------------------------------------------

    /// Generated TypeScript intentionally uses Web-standard APIs only so the
    /// public client remains portable to future V8-isolate/WASM packaging.
    #[test]
    fn ts_uses_fetch_and_avoids_node_native_imports() {
        let dir = tempfile::tempdir().unwrap();
        let config = make_config(dir.path());
        let paths = TypeScriptGenerator.generate(&config).unwrap();
        let ts = find_ts(&paths);
        let content = fs::read_to_string(ts).unwrap();

        assert!(content.contains("fetch("));
        for forbidden in [
            "from 'node:",
            "from \"node:",
            "require(",
            "Buffer",
            "process.",
            "fs.",
            "net.",
            "http.",
            "EventEmitter",
            "Readable",
        ] {
            assert!(
                !content.contains(forbidden),
                "generated TypeScript should avoid Node-native API marker {forbidden:?}"
            );
        }
    }

    /// The `.d.ts` file contains `export` declarations.
    #[test]
    fn dts_contains_exports() {
        let dir = tempfile::tempdir().unwrap();
        let config = make_config(dir.path());
        let paths = TypeScriptGenerator.generate(&config).unwrap();
        let dts = find_dts(&paths);
        let content = fs::read_to_string(dts).unwrap();
        assert!(content.contains("export"), "no exports found in .d.ts");
    }

    /// The `.d.ts` file declares the same function names as the `.ts` source.
    #[test]
    fn dts_declares_same_functions_as_ts() {
        let dir = tempfile::tempdir().unwrap();
        let config = make_config(dir.path());
        let paths = TypeScriptGenerator.generate(&config).unwrap();
        let dts = find_dts(&paths);
        let content = fs::read_to_string(dts).unwrap();
        assert!(content.contains("fetch"), "'fetch' not declared in .d.ts");
        assert!(content.contains("search"), "'search' not declared in .d.ts");
    }
}
