//! Python target — emits a Pydantic-typed client wrapping the PyO3
//! extension exposed by `mizan-rust`.
//!
//! Output shape lives at `templates/python/*.j2`. Per-method bodies are
//! pre-rendered in Rust before passing into `client.py.j2` so the template
//! only owns top-level section layout, not Python method-signature details.

use std::path::PathBuf;

use askama::Template;
use indexmap::IndexMap;

use crate::config::Config;
use crate::emit::CodegenTarget;
use crate::emit::EmittedFile;
use crate::emit::casing::{pascal_case, rust_ident, snake_case};
use crate::ir::{IsContext, JsonSchema, MizanContext, MizanFunction, MizanIR};


pub struct PythonClient;


impl CodegenTarget for PythonClient {
    fn name(&self) -> &'static str { "python" }

    fn emit(&self, ir: &MizanIR, _config: &Config) -> Vec<EmittedFile> {
        let schemas_block = ir.components.schemas.iter()
            .map(|(name, schema)| emit_schema_block(name, schema))
            .collect::<Vec<_>>()
            .join("\n\n");

        let types_py = TypesTemplate { schemas_block }.render().expect("types.py renders");
        let client_py = build_client_template(ir).render().expect("client.py renders");
        let init_py = InitTemplate {}.render().expect("__init__.py renders");

        vec![
            EmittedFile::new(PathBuf::from("types.py"), types_py),
            EmittedFile::new(PathBuf::from("client.py"), client_py),
            EmittedFile::new(PathBuf::from("__init__.py"), init_py),
        ]
    }
}


#[derive(Template)]
#[template(path = "python/__init__.py.j2", escape = "none")]
struct InitTemplate {}


#[derive(Template)]
#[template(path = "python/types.py.j2", escape = "none")]
struct TypesTemplate {
    schemas_block: String,
}


#[derive(Template)]
#[template(path = "python/client.py.j2", escape = "none")]
struct ClientTemplate {
    ctx_methods_block: String,
    call_methods_block: String,
    data_classes_block: String,
}


// ─── types.py schema bodies ────────────────────────────────────────────────


fn emit_schema_block(raw_name: &str, schema: &JsonSchema) -> String {
    let name = pascal_case(raw_name);

    if let Some(values) = &schema.r#enum {
        if schema.ty.as_deref() == Some("string") {
            let literal = values.iter()
                .filter_map(|v| v.as_str())
                .map(|v| format!("\"{v}\""))
                .collect::<Vec<_>>()
                .join(", ");
            return format!("{name} = Literal[{literal}]");
        }
    }

    if schema.ty.as_deref() == Some("array") {
        let elem = py_type_from_schema(schema.items.as_deref().unwrap_or(&JsonSchema::default()));
        return format!("{name} = list[{elem}]");
    }

    if schema.ty.as_deref() == Some("object") {
        if let Some(props) = &schema.properties {
            return emit_pydantic_class(&name, schema, props);
        }
    }

    let ty = py_type_from_schema(schema);
    format!("{name} = {ty}")
}


fn emit_pydantic_class(
    name: &str,
    schema: &JsonSchema,
    properties: &IndexMap<String, JsonSchema>,
) -> String {
    if properties.is_empty() {
        return format!("class {name}(BaseModel):\n    pass");
    }
    let required: std::collections::HashSet<&str> =
        schema.required.iter().map(String::as_str).collect();

    let field_lines = properties.iter()
        .map(|(field_raw, field_schema)| {
            let mut ty = py_type_from_schema(field_schema);
            let is_required = required.contains(field_raw.as_str())
                || field_schema.default.is_some();
            if !is_required {
                if !ty.ends_with(" | None") {
                    ty = format!("{ty} | None");
                }
                format!("    {}: {ty} = None", rust_ident(field_raw))
            } else {
                format!("    {}: {ty}", rust_ident(field_raw))
            }
        })
        .collect::<Vec<_>>()
        .join("\n");

    format!("class {name}(BaseModel):\n{field_lines}")
}


fn py_type_from_schema(schema: &JsonSchema) -> String {
    if let Some(ref_name) = schema.ref_name() {
        return pascal_case(ref_name);
    }

    if let Some(any_of) = &schema.any_of {
        let has_null = any_of.iter().any(|s| s.ty.as_deref() == Some("null"));
        let non_null: Vec<&JsonSchema> = any_of
            .iter()
            .filter(|s| s.ty.as_deref() != Some("null"))
            .collect();
        if has_null && non_null.len() == 1 {
            return format!("{} | None", py_type_from_schema(non_null[0]));
        }
    }

    let nullable = schema.nullable;
    let inner = inner_py_type(schema);
    if nullable {
        format!("{inner} | None")
    } else {
        inner
    }
}


fn inner_py_type(schema: &JsonSchema) -> String {
    if let Some(values) = &schema.r#enum {
        if schema.ty.as_deref() == Some("string") {
            let parts = values.iter()
                .filter_map(|v| v.as_str())
                .map(|v| format!("\"{v}\""))
                .collect::<Vec<_>>()
                .join(", ");
            return format!("Literal[{parts}]");
        }
    }
    match schema.ty.as_deref() {
        Some("integer") => "int".to_string(),
        Some("number") => "float".to_string(),
        Some("boolean") => "bool".to_string(),
        Some("string") => "str".to_string(),
        Some("array") => {
            let elem = py_type_from_schema(schema.items.as_deref().unwrap_or(&JsonSchema::default()));
            format!("list[{elem}]")
        }
        Some("object") => {
            if schema.properties.is_some() { "Any".to_string() }
            else { "dict[str, Any]".to_string() }
        }
        _ => "Any".to_string(),
    }
}


// ─── client.py method blocks ───────────────────────────────────────────────


fn build_client_template(ir: &MizanIR) -> ClientTemplate {
    let ctx_methods_block = ir.contexts.iter()
        .map(|(ctx_name, ctx_meta)| {
            let fetch = emit_fetch_method(ctx_name, ctx_meta);
            let subscribe = emit_subscribe_method(ctx_name, ctx_meta);
            format!("{fetch}{subscribe}")
        })
        .collect::<Vec<_>>()
        .join("\n");

    let call_methods_block = ir.functions.iter()
        .filter(|f| matches!(f.is_context, IsContext::No) && !f.is_form)
        .map(emit_call_method)
        .collect::<Vec<_>>()
        .join("\n");

    let data_classes_block = ir.contexts.iter()
        .map(|(ctx_name, _)| {
            let ctx_fns: Vec<&MizanFunction> = ir.functions.iter()
                .filter(|f| f.is_context.as_str() == Some(ctx_name))
                .collect();
            emit_context_data_class(ctx_name, &ctx_fns)
        })
        .collect::<Vec<_>>()
        .join("\n");

    ClientTemplate { ctx_methods_block, call_methods_block, data_classes_block }
}


fn py_arg_type(json_ty: &str) -> &'static str {
    match json_ty {
        "integer" => "int",
        "number" => "float",
        "boolean" => "bool",
        _ => "str",
    }
}


fn emit_fetch_method(ctx_name: &str, ctx_meta: &MizanContext) -> String {
    let method_name = format!("fetch_{}_context", snake_case(ctx_name));
    let param_args = ctx_meta.params.iter()
        .map(|(n, m)| {
            let ident = rust_ident(n);
            let ty = py_arg_type(&m.ty);
            if m.required { format!("{ident}: {ty}") }
            else { format!("{ident}: {ty} | None = None") }
        })
        .collect::<Vec<_>>()
        .join(", ");
    let param_dict = if ctx_meta.params.is_empty() {
        "{}".to_string()
    } else {
        let pairs = ctx_meta.params.iter()
            .map(|(n, _)| format!("\"{n}\": {}", rust_ident(n)))
            .collect::<Vec<_>>()
            .join(", ");
        format!("{{{pairs}}}")
    };
    let data_class = format!("{}ContextData", pascal_case(ctx_name));
    let arg_sig = if param_args.is_empty() { String::new() } else { format!(", {param_args}") };

    format!(
        "    def {method_name}(self{arg_sig}) -> \"{data_class}\":\n        raw = self._inner.fetch_context(\"{ctx_name}\", {param_dict})\n        return {data_class}(**raw)\n",
    )
}


fn emit_subscribe_method(ctx_name: &str, ctx_meta: &MizanContext) -> String {
    let param_args = ctx_meta.params.iter()
        .map(|(n, m)| {
            let ident = rust_ident(n);
            let ty = py_arg_type(&m.ty);
            if m.required { format!("{ident}: {ty}") }
            else { format!("{ident}: {ty} | None = None") }
        })
        .collect::<Vec<_>>()
        .join(", ");
    let param_dict = if ctx_meta.params.is_empty() {
        "{}".to_string()
    } else {
        let pairs = ctx_meta.params.iter()
            .map(|(n, _)| format!("\"{n}\": {}", rust_ident(n)))
            .collect::<Vec<_>>()
            .join(", ");
        format!("{{{pairs}}}")
    };
    let arg_sig = if param_args.is_empty() { String::new() } else { format!(", {param_args}") };
    let snake = snake_case(ctx_name);
    let indent_39 = " ".repeat(39);

    format!(
        "    def subscribe_{snake}_context(self{arg_sig},\n{indent_39}callback: Callable[[dict[str, Any]], None]) -> PyContextSubscription:\n        return self._inner.subscribe_context(\"{ctx_name}\", {param_dict}, callback)\n",
    )
}


fn emit_call_method(fn_meta: &MizanFunction) -> String {
    let method_name = format!("call_{}", snake_case(&fn_meta.name));
    let pascal_output = pascal_case(&fn_meta.output_type);

    let input_arg = if fn_meta.has_input {
        let it = fn_meta.input_type.as_deref().unwrap_or("");
        format!(", args: {}", pascal_case(it))
    } else {
        String::new()
    };
    let args_expr = if fn_meta.has_input { "args.model_dump()" } else { "{}" };
    let return_type = if fn_meta.output_nullable {
        format!("{pascal_output} | None")
    } else {
        pascal_output.clone()
    };
    let decode_expr = if fn_meta.output_nullable {
        format!("{pascal_output}(**raw) if raw is not None else None")
    } else {
        format!("{pascal_output}(**raw)")
    };

    format!(
        "    def {method_name}(self{input_arg}) -> {return_type}:\n        raw = self._inner.call(\"{wire}\", {args_expr})\n        return {decode_expr}\n",
        wire = fn_meta.name,
    )
}


fn emit_context_data_class(ctx_name: &str, ctx_fns: &[&MizanFunction]) -> String {
    let class_name = format!("{}ContextData", pascal_case(ctx_name));
    let field_lines = ctx_fns.iter()
        .map(|fn_meta| {
            let pascal_out = pascal_case(&fn_meta.output_type);
            let ty = if fn_meta.output_nullable { format!("{pascal_out} | None") } else { pascal_out };
            format!("    {}: {ty}", rust_ident(&fn_meta.name))
        })
        .collect::<Vec<_>>()
        .join("\n");
    format!(
        "class {class_name}(BaseModel):\n    \"\"\"Bundled return of fetch_{snake}_context.\"\"\"\n{field_lines}\n",
        snake = snake_case(ctx_name),
    )
}
