//! Create documentation pages for each rule. Pages are printed as Markdown and
//! get added to the oxc.rs website.
//! You can test/run this task with `just website ../website`,
//! assuming you have cloned the oxc website repo next to the oxc repo.

use std::{
    fmt::{self, Write},
    path::PathBuf,
};

use itertools::Itertools;
use oxc_linter::{LintPlugins, table::RuleTableRow};
use schemars::{
    JsonSchema, SchemaGenerator,
    schema::{InstanceType, Schema, SchemaObject, SingleOrVec},
};

use website_common::Renderer;

use super::HtmlWriter;

#[derive(Debug)]
pub(super) struct Context {
    page: HtmlWriter,
    schemas: SchemaGenerator,
    renderer: Renderer,
}

impl Context {
    pub fn new<T: JsonSchema + ?Sized>(mut schemas: SchemaGenerator) -> Self {
        let renderer = Renderer::new(schemas.root_schema_for::<T>());
        Self { page: HtmlWriter::with_capacity(1024), schemas, renderer }
    }

    pub fn render_rule_docs_page(&mut self, rule: &RuleTableRow) -> Result<String, fmt::Error> {
        const APPROX_FIX_CATEGORY_AND_PLUGIN_LEN: usize = 512;
        let RuleTableRow {
            name,
            documentation,
            schema,
            plugin,
            version,
            turned_on_by_default,
            autofix,
            category,
            is_tsgolint_rule,
        } = rule;
        let resolved =
            schema.as_ref().map(|schema| self.schemas.dereference(schema).unwrap_or(schema));

        self.page.reserve(
            documentation.map_or(0, str::len) + name.len() + APPROX_FIX_CATEGORY_AND_PLUGIN_LEN,
        );

        let normalized_plugin_name = get_normalized_plugin_name(plugin);
        let rule_name = full_rule_name(rule, normalized_plugin_name, true);

        // Write frontmatter
        let default = if *turned_on_by_default { "true" } else { "false" };
        let type_aware = if *is_tsgolint_rule { "true" } else { "false" };
        let fix = autofix.to_string();
        let upstream_line = upstream_docs_url(plugin, name)
            .map(|url| format!("upstream: \"{url}\"\n"))
            .unwrap_or_default();

        let file = file!();
        #[cfg(windows)]
        #[expect(clippy::disallowed_methods, reason = "file path always contains slashes")]
        let file = &*file.replace('\\', "/");

        writeln!(
            self.page,
            "---\ntitle: \"{rule_name} | Oxlint\"\nrule: \"{rule_name}\"\ncategory: \"{category}\"\nversion: \"{version}\"\ndefault: {default}\ntype_aware: {type_aware}\nfix: \"{fix}\"\n{upstream_line}---\n"
        )?;

        writeln!(
            self.page,
            "<!-- This file is auto-generated by {file}. Do not edit it manually. -->\n"
        )?;

        // If it's a tsgolint rule, provide the additional source link in the script tag. Otherwise, set tsgolint_source to a blank string.
        let tsgolint_source = if *is_tsgolint_rule {
            format!("\nconst tsgolintSource = `{}`;", tsgolint_rule_source(rule))
        } else {
            String::new()
        };

        writeln!(
            self.page,
            "<script setup>
import {{ data }} from '../version.data.js';
const source = `{}`;{}
</script>",
            rule_source(rule),
            // Will be empty string if not a tsgolint rule
            tsgolint_source
        )?;

        // Add a <RuleHeader> element, will be handled by the Vitepress site to render necessary data.
        writeln!(self.page, "\n<RuleHeader />")?;

        // rule documentation
        if let Some(docs) = documentation {
            writeln!(self.page, "\n{}", *docs)?;
        }

        // rule configuration
        if let Some(Schema::Object(schema)) = resolved {
            let config_section = self.rule_config(schema);
            // Pull rule configuration description from the schema metadata, if present.
            // The schemars `SchemaObject` may contain a `metadata` block with an
            // optional `description` field. If present, include it above the
            // configuration listing so readers see the intent for the config.
            let section_description = schema
                .metadata
                .as_ref()
                .and_then(|m| m.description.as_ref())
                .map(|desc| format!("\n{desc}\n"))
                .unwrap_or_default();
            if !config_section.trim().is_empty() {
                writeln!(self.page, "\n## Configuration\n{section_description}{config_section}")?;
            }
        }

        // how-to-use and rule references components.
        writeln!(
            self.page,
            "\n## How to use\n<RuleHowToUse />\n\n## Version\nThis rule was added in v{version}.\n\n## References\n<RuleReferences />"
        )?;

        Ok(self.page.take())
    }

    fn rule_config(&self, schema: &SchemaObject) -> String {
        if let Some(array) = &schema.array
            && let Some(SingleOrVec::Vec(options)) = &array.items
        {
            // multiple options
            return options
                .iter()
                .enumerate()
                .map(|(i, schema)| match schema {
                    Schema::Object(schema_object) => {
                        let section = self.renderer.render_schema(3, "", schema_object);
                        let title = format!("\n### The {} option\n", ordinal(i + 1));
                        let instance_type = section.instance_type.as_ref().map_or_else(
                            String::new,
                            |instance_type| {
                                if instance_type == "object" {
                                    "\nThis option is an object with the following properties:\n"
                                        .to_string()
                                } else {
                                    format!("\ntype: `{instance_type}`\n")
                                }
                            },
                        );
                        let rendered = section.to_md(&self.renderer);
                        let description = if section.description.is_empty() {
                            section.description
                        } else {
                            format!("\n{}\n", section.description)
                        };
                        format!("{title}{instance_type}{description}{rendered}")
                    }
                    Schema::Bool(_) => panic!(),
                })
                .join("");
        }
        let mut section = self.renderer.render_schema(2, "", schema);
        if section.default.is_none()
            && let Some(SingleOrVec::Single(ty)) = &schema.instance_type
        {
            match &**ty {
                InstanceType::Boolean => {
                    section.default = Some(format!("{}", <bool>::default()));
                }
                InstanceType::Array => {
                    section.default = Some("[]".to_string());
                }
                _ => {}
            }
        }
        let mut rendered = section.to_md(&self.renderer);
        if rendered.trim().is_empty() {
            // For primitive types (e.g. a single string argument) with no child
            // sections, render the section's own type and default info directly.
            let mut parts = String::new();
            if let Some(ref instance_type) = section.instance_type
                && !instance_type.is_empty()
            {
                write!(parts, "\ntype: `{instance_type}`\n").unwrap();
            }
            if let Some(ref default) = section.default {
                write!(parts, "\ndefault: `{default}`\n").unwrap();
            }
            if parts.trim().is_empty() {
                return rendered;
            }
            rendered = parts;
        }

        // Check if this is an enum-based config (oneOf with single-value enums)
        let is_enum_config =
            schema.subschemas.as_ref().and_then(|s| s.one_of.as_ref()).is_some_and(|variants| {
                variants.iter().all(|variant| {
                    if let Schema::Object(obj) = variant {
                        obj.enum_values.as_ref().is_some_and(|vals| vals.len() == 1)
                    } else {
                        false
                    }
                })
            });

        if schema.instance_type.as_ref().is_some_and(|it| it.contains(&InstanceType::Object)) {
            rendered = format!(
                "\nThis rule accepts a configuration object with the following properties:\n{rendered}\n"
            );
        } else if is_enum_config
            || (schema.instance_type.as_ref().is_some_and(|it| it.contains(&InstanceType::String))
                && schema.enum_values.is_some())
        {
            // Handle enum-based configurations (either direct enum or oneOf variants)
            rendered =
                format!("\nThis rule accepts one of the following string values:\n{rendered}\n");
        }

        rendered
    }
}

#[expect(clippy::disallowed_methods)]
fn rule_source(rule: &RuleTableRow) -> String {
    use project_root::get_project_root;
    use std::sync::OnceLock;
    const LINT_RULES_DIR: &str = "crates/oxc_linter/src/rules";
    static ROOT: OnceLock<PathBuf> = OnceLock::new();
    let root = ROOT.get_or_init(|| get_project_root().unwrap());

    // Some rules are folders with a mod.rs file, others are just a rust file
    let rule_name = rule.name.replace('-', "_");
    let mut rule_path = format!("{}/{}", rule.plugin, rule_name);
    if root.join(LINT_RULES_DIR).join(&rule.plugin).join(rule_name).is_dir() {
        rule_path.push_str("/mod.rs");
    } else {
        rule_path.push_str(".rs");
    }

    format!(
        "https://github.com/oxc-project/oxc/blob/${{ data }}/crates/oxc_linter/src/rules/{rule_path}"
    )
}

/// Returns the URL to the source code of a tsgolint rule.
/// Only applicable for tsgolint rules.
// TODO: Provide a way to link to a specific git ref? We'd need to update the justfile to pass it in correctly.
#[expect(clippy::disallowed_methods)]
fn tsgolint_rule_source(rule: &RuleTableRow) -> String {
    let rule_name = rule.name.replace('-', "_");
    let rule_path = format!("{rule_name}/{rule_name}.go");
    // Result: https://github.com/oxc-project/tsgolint/blob/main/internal/rules/prefer_reduce_type_parameter/prefer_reduce_type_parameter.go
    format!("https://github.com/oxc-project/tsgolint/blob/main/internal/rules/{rule_path}")
}

/// Returns the normalized plugin name.
/// - Example: `react_perf` -> `react-perf`
/// - Example: `eslint` -> `eslint`
/// - Example: `jsx_a11y` -> `jsx-a11y`
fn get_normalized_plugin_name(plugin: &str) -> &str {
    LintPlugins::try_from(plugin).unwrap_or(LintPlugins::empty()).into()
}

// Return the full rule name, including plugin prefix if necessary. For example:
// - If plugin is "eslint" and `explicit_plugin_name: false`, return "rule-name"
// - If plugin is "eslint" and `explicit_plugin_name: true`, return "eslint/rule-name"
// - If plugin is "react_perf" and `explicit_plugin_name: false`, return "react-perf/rule-name"
// - If plugin is "react_perf" and `explicit_plugin_name: true`, return "react-perf/rule-name"
fn full_rule_name(
    rule: &RuleTableRow,
    normalized_plugin_name: &str,
    explicit_plugin_name: bool,
) -> String {
    if normalized_plugin_name.is_empty() && explicit_plugin_name {
        format!("{}/{}", "eslint", rule.name)
    } else if normalized_plugin_name.is_empty() {
        rule.name.to_string()
    } else {
        format!("{}/{}", normalized_plugin_name, rule.name)
    }
}

/// Returns the upstream-plugin docs URL for `(plugin, name)`, where `name` is the
/// rule's kebab-case identifier. Returns `None` for native `oxc` rules or unknown
/// plugins. Per-rule overrides handle directories that do not match their true
/// upstream (e.g. React Hooks rules under `react/` are sourced from
/// `eslint-plugin-react-hooks`).
fn upstream_docs_url(plugin: &str, name: &str) -> Option<String> {
    let override_url: Option<&'static str> = match (plugin, name) {
        ("react", "rules-of-hooks") => Some("https://react.dev/reference/rules/rules-of-hooks"),
        ("react", "exhaustive-deps") => Some(
            "https://github.com/facebook/react/blob/main/packages/eslint-plugin-react-hooks/README.md",
        ),
        ("react", "only-export-components") => Some(
            "https://github.com/ArnaudBarre/eslint-plugin-react-refresh/blob/main/docs/only-export-components.md",
        ),
        _ => None,
    };
    if let Some(url) = override_url {
        return Some(url.to_owned());
    }
    Some(match plugin {
        "eslint" => format!("https://eslint.org/docs/latest/rules/{name}"),
        "typescript" => format!("https://typescript-eslint.io/rules/{name}/"),
        "jest" => format!(
            "https://github.com/jest-community/eslint-plugin-jest/blob/main/docs/rules/{name}.md"
        ),
        "unicorn" => format!(
            "https://github.com/sindresorhus/eslint-plugin-unicorn/blob/main/docs/rules/{name}.md"
        ),
        "import" => format!(
            "https://github.com/import-js/eslint-plugin-import/blob/main/docs/rules/{name}.md"
        ),
        "react" => format!(
            "https://github.com/jsx-eslint/eslint-plugin-react/blob/master/docs/rules/{name}.md"
        ),
        "react_perf" => format!(
            "https://github.com/cvazac/eslint-plugin-react-perf/blob/master/docs/rules/{name}.md"
        ),
        "jsx_a11y" => format!(
            "https://github.com/jsx-eslint/eslint-plugin-jsx-a11y/blob/main/docs/rules/{name}.md"
        ),
        "nextjs" => format!("https://nextjs.org/docs/messages/{name}"),
        "jsdoc" => {
            // jsdoc URLs use camelCase rule names.
            let mut camel = String::with_capacity(name.len());
            let mut upper = false;
            for c in name.chars() {
                if c == '-' {
                    upper = true;
                } else if upper {
                    camel.extend(c.to_uppercase());
                    upper = false;
                } else {
                    camel.push(c);
                }
            }
            format!("https://github.com/gajus/eslint-plugin-jsdoc/blob/main/docs/rules/{camel}.md")
        }
        "node" => format!(
            "https://github.com/eslint-community/eslint-plugin-n/blob/master/docs/rules/{name}.md"
        ),
        "promise" => format!(
            "https://github.com/eslint-community/eslint-plugin-promise/blob/main/docs/rules/{name}.md"
        ),
        "vitest" => format!(
            "https://github.com/vitest-dev/eslint-plugin-vitest/blob/main/docs/rules/{name}.md"
        ),
        "vue" => format!("https://eslint.vuejs.org/rules/{name}.html"),
        _ => return None,
    })
}

fn ordinal(n: usize) -> String {
    let suffix = match n % 100 {
        11..=13 => "th",
        _ => match n % 10 {
            1 => "st",
            2 => "nd", // spellchecker:disable-line
            3 => "rd",
            _ => "th",
        },
    };
    format!("{n}{suffix}")
}
