use std::collections::{BTreeMap, HashMap};

use orpc_procedure::ErasedSchema;
use specta::TypeCollection;
use specta::datatype::{DataType, FunctionResultVariant};
use specta_typescript::Typescript;

use crate::schema::SpectaSchema;

/// Error type for TypeScript export operations.
#[derive(Debug, thiserror::Error)]
pub enum ExportError {
    #[error("IO error: {0}")]
    Io(#[from] std::io::Error),
    #[error("TypeScript generation error: {0}")]
    Ts(String),
    #[error(
        "Duplicate type name \"{name}\" found in procedures: [{procedures}]. Use unique type names to avoid TypeScript conflicts."
    )]
    DuplicateTypeName { name: String, procedures: String },
}

/// Export TypeScript type definitions from a Router to a file.
///
/// Generates types compatible with `@orpc/client`:
///
/// ```typescript
/// import type { Client } from "@orpc/client"
/// export type Procedures = {
///   ping: Client<Record<never, never>, void, string, Error>
///   planet: {
///     find: Client<Record<never, never>, FindPlanetInput, Planet, Error>
///   }
/// }
/// ```
///
/// # Example
/// ```ignore
/// orpc_specta::export_ts(&router, "./bindings.ts")?;
/// ```
pub fn export_ts<TCtx: Send>(router: &orpc::Router<TCtx>, path: &str) -> Result<(), ExportError> {
    let content = generate_ts(router)?;
    std::fs::write(path, content)?;
    Ok(())
}

/// Generate TypeScript type definitions as a string.
pub fn generate_ts<TCtx: Send>(router: &orpc::Router<TCtx>) -> Result<String, ExportError> {
    let mut all_types = TypeCollection::default();
    let mut procedures: BTreeMap<String, ProcedureTypeInfo> = BTreeMap::new();

    for (key, proc) in router.procedures() {
        let input_dt = extract_and_collect(proc.input_schema.as_deref(), &mut all_types);
        let output_dt = extract_and_collect(proc.output_schema.as_deref(), &mut all_types);

        procedures.insert(
            key.clone(),
            ProcedureTypeInfo {
                input: input_dt,
                output: output_dt,
            },
        );
    }

    // Detect duplicate type names that would collide in TypeScript output.
    // TypeCollection keys by SpectaID (unique per Rust type), but different
    // types can share the same name (e.g. two `struct Input` in different modules).
    check_duplicate_type_names(&all_types)?;

    let exporter =
        Typescript::default().framework_header("// Auto-generated by orpc-specta. Do not edit.");

    let mut output = String::new();

    // Header + @orpc/client import
    output.push_str("// Auto-generated by orpc-specta. Do not edit.\n");
    output.push_str("import type { Client } from \"@orpc/client\"\n\n");

    // Render named type exports
    let type_defs = exporter
        .export(&all_types)
        .map_err(|e| ExportError::Ts(e.to_string()))?;
    if !type_defs.is_empty() {
        output.push_str(&type_defs);
    }

    // Render Procedures type
    output.push_str("export type Procedures = ");
    output.push_str(&render_procedures_type(&procedures, &exporter, &all_types)?);
    output.push('\n');

    Ok(output)
}

/// Check for duplicate type names in the collected types.
///
/// Different Rust types (different `SpectaID`) can share the same name when
/// defined in separate modules (e.g. both named `Input`). In TypeScript export,
/// only one definition would be emitted, causing silent type corruption.
fn check_duplicate_type_names(types: &TypeCollection) -> Result<(), ExportError> {
    let mut seen_names: HashMap<String, specta::SpectaID> = HashMap::new();
    for (sid, ndt) in types {
        let name = ndt.name().to_string();
        if let Some(prev_sid) = seen_names.get(&name) {
            if *prev_sid != sid {
                return Err(ExportError::DuplicateTypeName {
                    name,
                    procedures: "multiple Rust types share this name — use #[specta(rename = \"...\")] to disambiguate".to_string(),
                });
            }
        } else {
            seen_names.insert(name, sid);
        }
    }
    Ok(())
}

struct ProcedureTypeInfo {
    input: Option<DataType>,
    output: Option<DataType>,
}

fn extract_and_collect(
    schema: Option<&dyn ErasedSchema>,
    all_types: &mut TypeCollection,
) -> Option<DataType> {
    let schema = schema?;
    let specta_schema = schema.as_any().downcast_ref::<SpectaSchema>()?;
    all_types.extend(&specta_schema.types);
    Some(specta_schema.data_type.clone())
}

fn render_procedures_type(
    procedures: &BTreeMap<String, ProcedureTypeInfo>,
    exporter: &Typescript,
    types: &TypeCollection,
) -> Result<String, ExportError> {
    let mut root: BTreeMap<String, TreeNode> = BTreeMap::new();

    for (key, info) in procedures {
        let parts: Vec<&str> = key.split('.').collect();
        insert_into_tree(&mut root, &parts, info, exporter, types)?;
    }

    format_tree(&root, 0)
}

enum TreeNode {
    Leaf { input_ts: String, output_ts: String },
    Namespace(BTreeMap<String, TreeNode>),
}

/// Render a DataType as a TypeScript inline string.
fn render_datatype(
    exporter: &Typescript,
    types: &TypeCollection,
    dt: &DataType,
) -> Result<String, ExportError> {
    specta_typescript::datatype(exporter, &FunctionResultVariant::Value(dt.clone()), types)
        .map_err(|e| ExportError::Ts(e.to_string()))
}

fn insert_into_tree(
    tree: &mut BTreeMap<String, TreeNode>,
    path: &[&str],
    info: &ProcedureTypeInfo,
    exporter: &Typescript,
    types: &TypeCollection,
) -> Result<(), ExportError> {
    if path.is_empty() {
        return Ok(());
    }

    if path.len() == 1 {
        let input_ts = match &info.input {
            Some(dt) => render_datatype(exporter, types, dt)?,
            None => "void".to_string(),
        };
        let output_ts = match &info.output {
            Some(dt) => render_datatype(exporter, types, dt)?,
            None => "void".to_string(),
        };
        tree.insert(
            path[0].to_string(),
            TreeNode::Leaf {
                input_ts,
                output_ts,
            },
        );
        return Ok(());
    }

    let ns = tree
        .entry(path[0].to_string())
        .or_insert_with(|| TreeNode::Namespace(BTreeMap::new()));

    if let TreeNode::Namespace(children) = ns {
        insert_into_tree(children, &path[1..], info, exporter, types)?;
    }

    Ok(())
}

fn format_tree(tree: &BTreeMap<String, TreeNode>, indent: usize) -> Result<String, ExportError> {
    let pad = "  ".repeat(indent);
    let inner_pad = "  ".repeat(indent + 1);
    let mut out = String::from("{\n");

    for (name, node) in tree {
        match node {
            TreeNode::Leaf {
                input_ts,
                output_ts,
            } => {
                out.push_str(&format!(
                    "{inner_pad}{name}: Client<Record<never, never>, {input_ts}, {output_ts}, Error>\n"
                ));
            }
            TreeNode::Namespace(children) => {
                let nested = format_tree(children, indent + 1)?;
                out.push_str(&format!("{inner_pad}{name}: {nested}\n"));
            }
        }
    }

    out.push_str(&format!("{pad}}}"));
    Ok(out)
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::specta;
    use orpc::*;
    use orpc_procedure::{DynInput, DynOutput, ErasedProcedure, Meta, ProcedureStream, Route};
    use serde::{Deserialize, Serialize};
    use specta::Type;

    #[derive(Debug, Serialize, Deserialize, Type)]
    struct Planet {
        id: u32,
        name: String,
        radius_km: u32,
    }

    #[derive(Debug, Serialize, Deserialize, Type)]
    struct FindPlanetInput {
        name: String,
    }

    #[test]
    fn export_includes_orpc_client_import() {
        let router: Router<()> = Router::new();
        let ts = generate_ts(&router).unwrap();
        assert!(ts.contains("import type { Client } from \"@orpc/client\""));
    }

    #[test]
    fn export_empty_router() {
        let router: Router<()> = Router::new();
        let ts = generate_ts(&router).unwrap();
        assert!(ts.contains("export type Procedures ="));
    }

    #[test]
    fn export_router_with_specta_schemas() {
        let find_proc = ErasedProcedure::new(
            |_ctx: (), _input: DynInput| {
                ProcedureStream::from_future(async { Ok(DynOutput::new("ok")) })
            },
            Route::default(),
            Meta::default(),
        )
        .with_input_schema_boxed(specta::<FindPlanetInput>().into_erased())
        .with_output_schema_boxed(specta::<Planet>().into_erased());

        let router = Router::new().procedure("planet.find", find_proc);
        let ts = generate_ts(&router).unwrap();

        assert!(
            ts.contains("FindPlanetInput"),
            "should contain named type FindPlanetInput:\n{ts}"
        );
        assert!(
            ts.contains("Planet"),
            "should contain named type Planet:\n{ts}"
        );
        assert!(
            ts.contains("Client<Record<never, never>, FindPlanetInput, Planet, Error>"),
            "should use named type references in Client<>:\n{ts}"
        );
    }

    #[test]
    fn export_procedure_without_specta() {
        let proc = ErasedProcedure::new(
            |_ctx: (), _input: DynInput| {
                ProcedureStream::from_future(async { Ok(DynOutput::new("pong")) })
            },
            Route::default(),
            Meta::default(),
        );

        let router = Router::new().procedure("ping", proc);
        let ts = generate_ts(&router).unwrap();
        assert!(ts.contains("ping: Client<Record<never, never>, void, void, Error>"));
    }

    #[test]
    fn export_nested_procedures() {
        let proc_a = ErasedProcedure::new(
            |_ctx: (), _input: DynInput| {
                ProcedureStream::from_future(async { Ok(DynOutput::new("ok")) })
            },
            Route::default(),
            Meta::default(),
        )
        .with_input_schema_boxed(specta::<String>().into_erased());

        let proc_b = ErasedProcedure::new(
            |_ctx: (), _input: DynInput| {
                ProcedureStream::from_future(async { Ok(DynOutput::new("ok")) })
            },
            Route::default(),
            Meta::default(),
        )
        .with_output_schema_boxed(specta::<Planet>().into_erased());

        let router = Router::new()
            .procedure("api.v1.list", proc_a)
            .procedure("api.v1.get", proc_b);

        let ts = generate_ts(&router).unwrap();
        assert!(ts.contains("api:"));
        assert!(ts.contains("v1:"));
        assert!(ts.contains("list: Client<Record<never, never>, string, void, Error>"));
        assert!(ts.contains("get: Client<Record<never, never>, void, Planet, Error>"));
    }
}
