//! Generate an openapi.yaml file from the Zebra RPC methods

use std::{collections::HashMap, error::Error, fs::File, io::Write};

use quote::ToTokens;
use serde::Serialize;
use syn::LitStr;

use zebra_rpc::methods::*;

// The API server
const SERVER: &str = "http://localhost:8232";

// The API methods
#[derive(Serialize, Debug)]
struct Methods {
    paths: HashMap<String, HashMap<String, MethodConfig>>,
}

// The configuration for each method
#[derive(Serialize, Clone, Debug)]
struct MethodConfig {
    tags: Vec<String>,
    description: String,
    #[serde(rename = "requestBody")]
    request_body: RequestBody,
    responses: HashMap<String, Response>,
}

// The request body
#[derive(Serialize, Clone, Debug)]
struct RequestBody {
    required: bool,
    content: Content,
}

// The content of the request body
#[derive(Serialize, Clone, Debug)]
struct Content {
    #[serde(rename = "application/json")]
    application_json: Application,
}

// The application of the request body
#[derive(Serialize, Clone, Debug)]
struct Application {
    schema: Schema,
}

// The schema of the request body
#[derive(Serialize, Clone, Debug)]
struct Schema {
    #[serde(rename = "type")]
    type_: String,
    properties: HashMap<String, Property>,
}

// The properties of the request body
#[derive(Serialize, Clone, Debug)]
struct Property {
    #[serde(rename = "type")]
    type_: String,
    #[serde(skip_serializing_if = "Option::is_none")]
    items: Option<ArrayItems>,
    default: String,
}

// The response
#[derive(Serialize, Clone, Debug)]
struct Response {
    description: String,
    content: Content,
}

// The array items
#[derive(Serialize, Clone, Debug)]
struct ArrayItems {}

fn main() -> Result<(), Box<dyn Error>> {
    let current_path = env!("CARGO_MANIFEST_DIR");

    // Define the paths to the Zebra RPC methods
    let paths = vec![
        (
            format!("{}/../zebra-rpc/src/methods.rs", current_path),
            "Rpc",
        ),
        (
            format!(
                "{}/../zebra-rpc/src/methods/get_block_template_rpcs.rs",
                current_path
            ),
            "GetBlockTemplateRpc",
        ),
    ];

    // Create a hashmap to store the method names and configuration
    let mut methods = HashMap::new();

    for zebra_rpc_methods_path in paths {
        // Read the source code from the file
        let source_code = std::fs::read_to_string(zebra_rpc_methods_path.0)?;

        // Parse the source code into a syn AST
        let syn_file = syn::parse_file(&source_code)?;

        // Create a hashmap to store the methods configuration
        let mut methods_config = HashMap::new();

        // Iterate over items in the file looking for traits
        for item in &syn_file.items {
            if let syn::Item::Trait(trait_item) = item {
                // Check if this trait is the one we're interested in
                if trait_item.ident == zebra_rpc_methods_path.1 {
                    // Iterate over the trait items looking for methods
                    for trait_item in &trait_item.items {
                        // Extract method name
                        let method_name = method_name(trait_item)?;

                        // Extract method documentation and description
                        let (method_doc, mut description) = method_doc(trait_item)?;

                        // Request type. TODO: All methods are POST so we just hardcode it
                        let request_type = "post".to_string();

                        // Tags. TODO: We are assuming 1 tag per call for now
                        let tags = tags(&method_doc)?;

                        // Parameters
                        let mut parameters_example = "[]".to_string();
                        if let Ok((params_description, params_example)) = get_params(&method_doc) {
                            // Add parameters to method description:
                            description =
                                add_params_to_description(&description, &params_description);
                            // The Zebra API uses a `params` array to pass arguments to the RPC methods,
                            // so we need to add this to the OpenAPI spec instead of `parameters`
                            parameters_example = params_example;
                        }

                        // Create the request body
                        let request_body = create_request_body(&method_name, &parameters_example);

                        // Check if we have parameters
                        let mut have_parameters = true;
                        if parameters_example == "[]" {
                            have_parameters = false;
                        }

                        // Create the responses
                        let responses = create_responses(&method_name, have_parameters)?;

                        // Add the method configuration to the hashmap
                        methods_config.insert(
                            request_type,
                            MethodConfig {
                                tags,
                                description,
                                request_body,
                                responses,
                            },
                        );

                        // Add the method name and configuration to the hashmap
                        methods.insert(format!("/{}", method_name), methods_config.clone());
                    }
                }
            }
        }
    }

    // Create a struct to hold all the methods
    let all_methods = Methods { paths: methods };

    // Add openapi header and write to file
    let yaml_string = serde_yaml::to_string(&all_methods)?;
    let mut w = File::create("openapi.yaml")?;
    w.write_all(format!("{}{}", create_yaml(), yaml_string).as_bytes())?;

    Ok(())
}

// Create the openapi.yaml header
fn create_yaml() -> String {
    format!("openapi: 3.0.3
info:
    title: Swagger Zebra API - OpenAPI 3.0
    version: 0.0.1
    description: |-
        This is the Zebra API. It is a JSON-RPC 2.0 API that allows you to interact with the Zebra node.

        Useful links:
        - [The Zebra repository](https://github.com/ZcashFoundation/zebra)
        - [The latests API spec](https://github.com/ZcashFoundation/zebra/blob/main/openapi.yaml)
servers:
  - url: {}
", SERVER)
}

// Extract the method name from the trait item
fn method_name(trait_item: &syn::TraitItem) -> Result<String, Box<dyn Error>> {
    let mut method_name = "".to_string();
    if let syn::TraitItem::Fn(method) = trait_item {
        method_name = method.sig.ident.to_string();

        // Refine name if needed
        method.attrs.iter().for_each(|attr| {
            if attr.path().is_ident("rpc") {
                let _ = attr.parse_nested_meta(|meta| {
                    method_name = meta.value()?.parse::<LitStr>()?.value();
                    Ok(())
                });
            }
        });
    }
    Ok(method_name)
}

// Return the method docs array and the description of the method
fn method_doc(method: &syn::TraitItem) -> Result<(Vec<String>, String), Box<dyn Error>> {
    let mut method_doc = vec![];
    if let syn::TraitItem::Fn(method) = method {
        // Filter only doc attributes
        let doc_attrs: Vec<_> = method
            .attrs
            .iter()
            .filter(|attr| attr.path().is_ident("doc"))
            .collect();

        // If no doc attributes found, return an error
        if doc_attrs.is_empty() {
            return Err("No documentation attribute found for the method".into());
        }

        method.attrs.iter().for_each(|attr| {
            if attr.path().is_ident("doc") {
                method_doc.push(attr.to_token_stream().to_string());
            }
        });
    }

    // Extract the description from the first line of documentation
    let description = match method_doc[0].split_once('"') {
        Some((_, desc)) => desc.trim().to_string().replace('\'', "''"),
        None => return Err("Description not found in method documentation".into()),
    };

    Ok((method_doc, description))
}

// Extract the tags from the method documentation. TODO: Assuming 1 tag per method for now
fn tags(method_doc: &[String]) -> Result<Vec<String>, Box<dyn Error>> {
    // Find the line containing tags information
    let tags_line = method_doc
        .iter()
        .find(|line| line.contains("tags:"))
        .ok_or("Tags not found in method documentation")?;

    // Extract tags from the tags line
    let mut tags = Vec::new();
    let tags_str = tags_line
        .split(':')
        .nth(1)
        .ok_or("Invalid tags line")?
        .trim();

    // Split the tags string into individual tags
    for tag in tags_str.split(',') {
        let trimmed_tag = tag.trim_matches(|c: char| !c.is_alphanumeric());
        if !trimmed_tag.is_empty() {
            tags.push(trimmed_tag.to_string());
        }
    }

    Ok(tags)
}

// Extract the parameters from the method documentation
fn get_params(method_doc: &[String]) -> Result<(String, String), Box<dyn Error>> {
    // Find the start and end index of the parameters
    let params_start_index = method_doc
        .iter()
        .enumerate()
        .find(|(_, line)| line.contains("# Parameters"));
    let notes_start_index = method_doc
        .iter()
        .enumerate()
        .find(|(_, line)| line.contains("# Notes"));

    // If start and end indices of parameters are found, extract them
    if let (Some((params_index, _)), Some((notes_index, _))) =
        (params_start_index, notes_start_index)
    {
        let params = &method_doc[params_index + 2..notes_index - 1];

        // Initialize variables to store parameter descriptions and examples
        let mut param_descriptions = Vec::new();
        let mut param_examples = Vec::new();

        // Iterate over the parameters and extract information
        for param_line in params {
            // Check if the line starts with the expected format
            if param_line.trim().starts_with("# [doc = \" -") {
                // Extract parameter name and description
                if let Some((name, description)) = extract_param_info(param_line) {
                    param_descriptions.push(format!("- `{}` - {}", name, description));

                    // Extract parameter example if available
                    if let Some(example) = extract_param_example(param_line) {
                        param_examples.push(example);
                    }
                }
            }
        }

        // Format parameters and examples
        let params_formatted = format!("[{}]", param_examples.join(", "));
        let params_description = param_descriptions.join("\n");

        return Ok((params_description, params_formatted));
    }

    Err("No parameters found".into())
}

// Extract parameter name and description
fn extract_param_info(param_line: &str) -> Option<(String, String)> {
    let start_idx = param_line.find('`')?;
    let end_idx = param_line.rfind('`')?;
    let name = param_line[start_idx + 1..end_idx].trim().to_string();

    let description_starts = param_line.find(") ")?;
    let description_ends = param_line.rfind("\"]")?;
    let description = param_line[description_starts + 2..description_ends]
        .trim()
        .to_string();

    Some((name, description))
}

// Extract parameter example if available
fn extract_param_example(param_line: &str) -> Option<String> {
    if let Some(example_start) = param_line.find("example=") {
        let example_ends = param_line.rfind(')')?;
        let example = param_line[example_start + 8..example_ends].trim();
        Some(example.to_string())
    } else {
        None
    }
}

// Create the request body
fn create_request_body(method_name: &str, parameters_example: &str) -> RequestBody {
    // Add the method name to the request body
    let method_name_prop = Property {
        type_: "string".to_string(),
        items: None,
        default: method_name.to_string(),
    };

    // Add a hardcoded request_id to the request body
    let request_id_prop = Property {
        type_: "number".to_string(),
        items: None,
        default: "123".to_string(),
    };

    // Create the schema and add the first 2 properties
    let mut schema = HashMap::new();
    schema.insert("method".to_string(), method_name_prop);
    schema.insert("id".to_string(), request_id_prop);

    // Add the parameters with the extracted examples
    let default = parameters_example.replace('\\', "");
    schema.insert(
        "params".to_string(),
        Property {
            type_: "array".to_string(),
            items: Some(ArrayItems {}),
            default,
        },
    );

    // Create the request body
    let content = Content {
        application_json: Application {
            schema: Schema {
                type_: "object".to_string(),
                properties: schema,
            },
        },
    };

    RequestBody {
        required: true,
        content,
    }
}

// Create the responses
fn create_responses(
    method_name: &str,
    have_parameters: bool,
) -> Result<HashMap<String, Response>, Box<dyn Error>> {
    let mut responses = HashMap::new();

    let properties = get_default_properties(method_name)?;

    let res_ok = Response {
        description: "OK".to_string(),
        content: Content {
            application_json: Application {
                schema: Schema {
                    type_: "object".to_string(),
                    properties,
                },
            },
        },
    };
    responses.insert("200".to_string(), res_ok);

    let mut properties = HashMap::new();
    if have_parameters {
        properties.insert(
            "error".to_string(),
            Property {
                type_: "string".to_string(),
                items: None,
                default: "Invalid parameters".to_string(),
            },
        );
        let res_bad_request = Response {
            description: "Bad request".to_string(),
            content: Content {
                application_json: Application {
                    schema: Schema {
                        type_: "object".to_string(),
                        properties,
                    },
                },
            },
        };
        responses.insert("400".to_string(), res_bad_request);
    }

    Ok(responses)
}

// Add the parameters to the method description
fn add_params_to_description(description: &str, params_description: &str) -> String {
    let mut new_description = description.to_string();
    new_description.push_str("\n\n**Request body `params` arguments:**\n\n");
    new_description.push_str(params_description);
    new_description
}

// Get requests examples by using defaults from the Zebra RPC methods
fn get_default_properties(method_name: &str) -> Result<HashMap<String, Property>, Box<dyn Error>> {
    // TODO: Complete the list of methods

    let type_ = "object".to_string();
    let items = None;
    let mut props = HashMap::new();

    let properties = match method_name {
        "getinfo" => {
            props.insert(
                "result".to_string(),
                Property {
                    type_,
                    items,
                    default: serde_json::to_string(&GetInfo::default())?,
                },
            );
            props
        }
        "getbestblockhash" => {
            props.insert(
                "result".to_string(),
                Property {
                    type_,
                    items,
                    default: serde_json::to_string(&GetBlockHash::default())?,
                },
            );
            props
        }
        "getblockchaininfo" => {
            props.insert(
                "result".to_string(),
                Property {
                    type_,
                    items,
                    default: serde_json::to_string(&GetBlockChainInfo::default())?,
                },
            );
            props
        }
        "getblock" => {
            props.insert(
                "result".to_string(),
                Property {
                    type_,
                    items,
                    default: serde_json::to_string(&GetBlock::default())?,
                },
            );
            props
        }
        "getblockhash" => {
            props.insert(
                "result".to_string(),
                Property {
                    type_,
                    items,
                    default: serde_json::to_string(&GetBlockHash::default())?,
                },
            );
            props
        }
        "z_gettreestate" => {
            props.insert(
                "result".to_string(),
                Property {
                    type_,
                    items,
                    default: serde_json::to_string(&GetTreestate::default())?,
                },
            );
            props
        }
        _ => {
            props.insert(
                "result".to_string(),
                Property {
                    type_,
                    items: None,
                    default: "{}".to_string(),
                },
            );
            props
        }
    };
    Ok(properties)
}
