use std::cmp::Ordering;
use std::collections::HashMap;
use std::error::Error;
use std::fmt::Write;
use std::path::{Path, PathBuf};
use std::process::exit;
use std::str::FromStr;

use clap::{Arg, Command, CommandFactory};
use fs_err as fs;
use itertools::Itertools;

const MD_EXTENSION: &str = ".md";

/// Markers in `zensical.toml` between which the CLI navigation is written.
const NAV_START_MARKER: &str =
    "# The CLI nav below is generated by rattler_build_docs. Do not edit manually!";
const NAV_END_MARKER: &str = "# End of generated CLI nav.";

/// Generates documentation for the Rattler-Build CLI by:
/// - Loading the clap command from the rattler-build crate
/// - Creating markdown files for each command
/// - Organizing commands into a directory structure
/// - Writing the navigation for these files into `zensical.toml`
fn main() -> Result<(), Box<dyn Error>> {
    let command = rattler_build::opt::App::command();

    let output_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../docs/reference/cli");

    println!("Generating CLI documentation in {}", output_dir.display());

    process_subcommands(&command, Vec::new(), &output_dir)?;

    let manifest_path = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../zensical.toml");
    update_nav(&command, &manifest_path)?;

    Ok(())
}

/// Returns the path of a command's page relative to the CLI docs root.
/// Commands with subcommands become directory index pages so that their
/// nav section header links to them (`navigation.indexes`).
fn page_path(parents: &[String], command: &Command) -> String {
    let name = command.get_name();
    let prefix = if parents.is_empty() {
        String::new()
    } else {
        format!("{}/", parents.join("/"))
    };
    if command.has_subcommands() {
        format!("{prefix}{name}/index{MD_EXTENSION}")
    } else {
        format!("{prefix}{name}{MD_EXTENSION}")
    }
}

/// Generates a TOML nav entry for a command, nesting visible subcommands.
/// Commands with subcommands become sections that link to the command's own
/// page through their index entry.
fn nav_entry(command: &Command, parents: &[String], indent: usize) -> Option<String> {
    if command.is_hide_set() {
        return None;
    }

    let name = command.get_name();
    let page = format!("reference/cli/{}", page_path(parents, command));
    let pad = "  ".repeat(indent);

    let visible: Vec<_> = command
        .get_subcommands()
        .filter(|subcommand| !subcommand.is_hide_set())
        .collect();
    if visible.is_empty() {
        return Some(format!("{pad}{{ \"{name}\" = \"{page}\" }},\n"));
    }

    let mut buffer = String::new();
    writeln!(buffer, "{pad}{{ \"{name}\" = [").unwrap();
    writeln!(buffer, "{pad}  \"{page}\",").unwrap();
    let mut child_parents = parents.to_vec();
    child_parents.push(name.to_string());
    for subcommand in visible {
        if let Some(entry) = nav_entry(subcommand, &child_parents, indent + 1) {
            buffer.push_str(&entry);
        }
    }
    writeln!(buffer, "{pad}] }},").unwrap();
    Some(buffer)
}

/// Writes the CLI navigation between the markers in `zensical.toml`.
fn update_nav(command: &Command, manifest_path: &Path) -> Result<(), Box<dyn Error>> {
    let content = fs::read_to_string(manifest_path)?;

    let start_marker = content
        .find(NAV_START_MARKER)
        .ok_or("missing CLI nav start marker in zensical.toml")?;
    let nav_start = start_marker
        + content[start_marker..]
            .find('\n')
            .ok_or("missing newline after CLI nav start marker in zensical.toml")?
        + 1;
    let end_marker = content
        .find(NAV_END_MARKER)
        .ok_or("missing CLI nav end marker in zensical.toml")?;
    if end_marker < nav_start {
        return Err("CLI nav end marker precedes start marker in zensical.toml".into());
    }
    let nav_end = content[..end_marker].rfind('\n').map_or(0, |i| i + 1);

    // The markers sit two levels deep in the nav array, inside "References"
    let indent = 2;
    let pad = "  ".repeat(indent);
    let mut nav = String::new();
    writeln!(nav, "{pad}{{ \"CLI\" = [").unwrap();
    writeln!(nav, "{pad}  \"reference/cli/{}\",", page_path(&[], command)).unwrap();
    let parents = vec![command.get_name().to_string()];
    for subcommand in command.get_subcommands() {
        if let Some(entry) = nav_entry(subcommand, &parents, indent + 1) {
            nav.push_str(&entry);
        }
    }
    writeln!(nav, "{pad}] }},").unwrap();

    let updated = format!("{}{}{}", &content[..nav_start], nav, &content[nav_end..]);
    fs::write(manifest_path, updated)?;

    println!("Updated CLI navigation in {}", manifest_path.display());

    Ok(())
}

/// Converts a command to Markdown format
fn subcommand_to_md(parents: &[String], command: &Command) -> String {
    let mut buffer = String::with_capacity(1024);

    // Generate title from command path
    let mut title_parts = parents.to_vec();
    title_parts.push(command.get_name().to_string());
    let title = title_parts.join(" ");

    // Write YAML frontmatter with title
    writeln!(buffer, "---").unwrap();
    writeln!(buffer, "title: {title}").unwrap();
    writeln!(buffer, "---").unwrap();

    // Start with autogenerated header
    writeln!(
        buffer,
        "<!--- This file is autogenerated. Do not edit manually! -->"
    )
    .unwrap();

    // Name with correct relative links to the parents' index pages. The
    // current page's directory depends on whether it is an index page itself.
    let mut name_parts = Vec::new();
    let depth = parents.len() + usize::from(command.has_subcommands());

    // Handle parents
    for (i, parent) in parents.iter().enumerate() {
        let ups = depth - i - 1;
        let relative_path = format!("{}index.md", "../".repeat(ups));
        name_parts.push(format!("[{parent}]({relative_path}) "));
    }

    // Add current command without link
    name_parts.push(command.get_name().to_string());

    // Title
    writeln!(buffer, "# {}", name_parts.join("")).unwrap();

    // About
    if command.get_name() != "rattler-build"
        && let Some(about) = command.get_about()
    {
        writeln!(buffer, "\n{about}").unwrap();
    }

    // Usage
    writeln!(buffer, "\n## Usage").unwrap();
    writeln!(buffer, "```").unwrap();
    {
        let mut command = command.clone();
        writeln!(
            buffer,
            "{}{}{}",
            parents.join(" "),
            if parents.is_empty() { "" } else { " " },
            command
                .render_usage()
                .to_string()
                .trim_start_matches("Usage: ")
        )
        .unwrap();
    }
    writeln!(buffer, "```").unwrap();

    // Subcommands
    if command.has_subcommands() {
        writeln!(buffer, "\n## Subcommands").unwrap();
        let subcommands: Vec<_> = command.get_subcommands().collect();
        if !subcommands.is_empty() {
            writeln!(
                buffer,
                "{}",
                subcommands_table(subcommands, command.get_name())
            )
            .unwrap();
        }
    }

    // Get a list of the parents and command name
    let mut parts = parents.to_vec();
    parts.push(command.get_name().to_string());

    // Positionals
    let positionals: Vec<_> = command.get_positionals().collect();
    if !positionals.is_empty() {
        writeln!(buffer, "\n## Arguments").unwrap();
        write!(buffer, "{}", arguments(&positionals, &parts)).unwrap();
    }

    // Options
    let opts: Vec<_> = command.get_opts().collect();
    if !opts.is_empty() {
        // All options with their header
        let header_option_map: HashMap<String, Vec<&Arg>> = opts
            .iter()
            .map(|o| {
                (
                    o.get_help_heading().unwrap_or("Options").to_string(),
                    o.to_owned(),
                )
            })
            .into_group_map();

        let sorted_header_options = header_option_map
            .iter()
            .sorted_by(|a, b| {
                match (a.0.as_str(), b.0.as_str()) {
                    // "Options" comes first
                    ("Options", _) => Ordering::Less,
                    (_, "Options") => Ordering::Greater,
                    // "Global Options" comes last
                    ("Global Options", _) => Ordering::Greater,
                    (_, "Global Options") => Ordering::Less,
                    // Alphabetical for others
                    _ => a.0.cmp(b.0),
                }
            })
            .filter_map(|(header, opts)| {
                // If all options are hidden, skip this header
                if opts.iter().all(|o| o.is_hide_set()) {
                    None
                } else {
                    Some((header, opts))
                }
            });

        for (header, opts) in sorted_header_options {
            writeln!(buffer, "\n## {header}").unwrap();
            write!(buffer, "{}", arguments(opts, &parts)).unwrap();
        }
    }

    // Long about
    if let Some(long) = command.get_long_about() {
        writeln!(buffer, "\n## Description").unwrap();
        writeln!(buffer, "{}\n", long.to_string().trim_end()).unwrap();
    }

    buffer
}

/// Processes a command and its subcommands, generating markdown documentation
fn process_subcommands(
    command: &Command,
    parent_path: Vec<String>,
    output_dir: &Path,
) -> Result<(), Box<dyn Error>> {
    let mut current_path = parent_path;
    current_path.push(command.get_name().to_string());

    let command_file_name = page_path(&current_path[..current_path.len() - 1], command);
    let command_file_path = output_dir.join(&command_file_name);

    fs::create_dir_all(command_file_path.parent().ok_or("invalid path")?)
        .map_err(|e| format!("failed to create directories: {e}"))?;
    fs::write(
        &command_file_path,
        subcommand_to_md(&current_path[..current_path.len() - 1], command),
    )
    .map_err(|e| {
        format!(
            "failed to write command file {}: {}",
            command_file_path.display(),
            e
        )
    })?;

    for subcommand in command.get_subcommands() {
        process_subcommands(subcommand, current_path.clone(), output_dir)?;
    }

    Ok(())
}

/// Generates a Markdown table of subcommands with command names as links to their pages
fn subcommands_table(subcommands: Vec<&Command>, parent: &str) -> String {
    let mut buffer = String::with_capacity(1024);
    writeln!(buffer, "| Command | Description |").unwrap();
    writeln!(buffer, "|---------|-------------|").unwrap();
    for subcommand in subcommands {
        // Skip hidden subcommands
        if subcommand.is_hide_set() {
            continue;
        }

        let about = if let Some(about) = subcommand.get_about() {
            about
        } else {
            eprintln!(
                "Warning: Subcommand `{} {}` has no description",
                parent,
                subcommand.get_name()
            );
            exit(1);
        };
        // Create a link to the subcommand's Markdown file, relative to the
        // parent's index page
        let command_name = subcommand.get_name();
        let link = page_path(&[], subcommand);
        let link_md = format!("[`{command_name}`]({link})");
        writeln!(buffer, "| {link_md} | {about} |",).unwrap();
    }
    buffer
}

/// Writes a list of options to the buffer
fn arguments(options: &[&clap::Arg], parents: &[String]) -> String {
    let mut buffer = String::with_capacity(1024);
    for opt in options {
        if opt.is_hide_set() ||
            // Skip short only as that is a bug in clap's disable help function.
            (opt.get_long().is_none() && opt.get_short().is_some())
        {
            continue;
        }

        let long_name = if let Some(long) = opt.get_long() {
            format!("--{long}")
        } else if let Some(value_names) = opt.get_value_names() {
            // No long name, but we have value names, assuming positional.
            format!("<{}>", value_names[0])
        } else {
            eprintln!(
                "Error: Option: '{:?}' with parents {} has no long name",
                opt,
                parents.join(" ")
            );
            exit(1);
        };

        // Error on missing help
        if opt.get_help().is_none() && opt.get_long().unwrap_or_default() != "help" {
            eprintln!(
                "Error: Option `{} {}` has no description",
                parents.join(" "),
                long_name
            );
            exit(1);
        }

        // Strip the angle brackets of positionals so that the id survives
        // Markdown rendering unescaped and the fragment link stays valid.
        let id = format!("arg-{}", long_name.trim_matches(['<', '>']));

        // Write the option as a bullet point with a self-referential <a> tag
        write!(
            buffer,
            "- <a id=\"{}\" href=\"#{}\">`{}{}{}`</a>\n:",
            id,
            id,
            long_name,
            if let Some(short) = opt.get_short() {
                format!(" (-{short})")
            } else {
                String::new()
            },
            if opt.get_action().takes_values() && !opt.is_positional() {
                if let Some(value_names) = opt.get_value_names() {
                    let sep = if opt.is_require_equals_set() {
                        "="
                    } else {
                        " "
                    };
                    format!("{}<{}>", sep, value_names.join(" "))
                } else {
                    String::new()
                }
            } else {
                String::new()
            }
        )
        .unwrap();

        // Write the help text
        if let Some(help) = opt.get_help() {
            writeln!(buffer, "  {help}").unwrap();
        } else {
            writeln!(buffer).unwrap();
        }

        // Write if it might be provided more than once
        if matches!(opt.get_action(), clap::ArgAction::Append) {
            writeln!(buffer, "<br>May be provided more than once.").unwrap();
        }

        // Add required
        if opt.is_required_set() {
            writeln!(buffer, "<br>**required**: `true`").unwrap();
        }

        // Handle aliases, env, defaults, and options
        if let Some(aliases) = opt.get_visible_aliases()
            && !aliases.is_empty()
        {
            writeln!(buffer, "<br>**aliases**: {}", aliases.join(", ")).unwrap();
        }
        if let Some(env) = opt.get_env() {
            writeln!(buffer, "<br>**env**: `{}`", env.to_string_lossy()).unwrap();
        }
        if !opt.get_default_values().is_empty() {
            writeln!(
                buffer,
                "<br>**default**: `{}`",
                opt.get_default_values()
                    .iter()
                    .map(|value| {
                        if rattler_conda_types::Platform::from_str(
                            value.as_os_str().to_str().unwrap(),
                        )
                        .is_ok()
                        {
                            "current_platform".to_string()
                        } else {
                            value.to_string_lossy().to_string()
                        }
                    })
                    .join(", ")
            )
            .unwrap();
        }
        if opt.get_action().takes_values() && !opt.get_possible_values().is_empty() {
            writeln!(
                buffer,
                "<br>**options**: `{}`",
                opt.get_possible_values()
                    .iter()
                    .map(|value| value.get_name())
                    .join("`, `")
            )
            .unwrap();
        }
    }
    buffer
}
