use std::{
    ffi::OsStr,
    path::{Path, PathBuf},
};

use rustc_hash::{FxHashMap, FxHashSet};
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};

use oxc_diagnostics::OxcDiagnostic;

use crate::{AllowWarnDeny, LintPlugins, utils::read_to_string};

use super::{
    categories::OxlintCategories,
    env::OxlintEnv,
    external_plugins::{ExternalPluginEntry, external_plugins_schema},
    globals::OxlintGlobals,
    overrides::OxlintOverrides,
    rules::OxlintRules,
    settings::OxlintSettings,
};

/// Options for the linter.
#[derive(Debug, Default, Clone, Deserialize, Serialize, JsonSchema)]
#[serde(default, deny_unknown_fields, rename_all = "camelCase")]
#[non_exhaustive]
pub struct OxlintOptions {
    /// Enable rules that require type information.
    ///
    /// Equivalent to passing `--type-aware` on the CLI.
    ///
    /// Note that this requires the `oxlint-tsgolint` package to be installed.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub type_aware: Option<bool>,
    /// Enable experimental type checking (includes TypeScript compiler diagnostics).
    ///
    /// Equivalent to passing `--type-check` on the CLI.
    ///
    /// Note that this requires the `oxlint-tsgolint` package to be installed.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub type_check: Option<bool>,
    /// Ensure warnings produce a non-zero exit code.
    ///
    /// Equivalent to passing `--deny-warnings` on the CLI.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub deny_warnings: Option<bool>,
    /// Specify a warning threshold. Exits with an error status if warnings exceed this value.
    ///
    /// Equivalent to passing `--max-warnings` on the CLI.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub max_warnings: Option<usize>,
    /// Report unused disable directives (e.g. `// oxlint-disable-line` or `// eslint-disable-line`).
    ///
    /// Equivalent to passing `--report-unused-disable-directives-severity` on the CLI.
    /// CLI flags take precedence over this value when both are set.
    /// Only supported in the root configuration file.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub report_unused_disable_directives: Option<AllowWarnDeny>,
    /// Whether oxlint should respect `eslint-disable*` and `eslint-enable*`
    /// directives in addition to its native `oxlint-*` directives.
    ///
    /// Defaults to `true`.
    /// Only supported in the root configuration file.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub respect_eslint_disable_directives: Option<bool>,
}

impl OxlintOptions {
    #[must_use]
    pub fn is_empty(&self) -> bool {
        self.type_aware.is_none()
            && self.type_check.is_none()
            && self.deny_warnings.is_none()
            && self.max_warnings.is_none()
            && self.report_unused_disable_directives.is_none()
            && self.respect_eslint_disable_directives.is_none()
    }

    #[must_use]
    pub fn merge(&self, other: &OxlintOptions) -> Self {
        Self {
            type_aware: self.type_aware.or(other.type_aware),
            type_check: self.type_check.or(other.type_check),
            deny_warnings: self.deny_warnings.or(other.deny_warnings),
            max_warnings: self.max_warnings.or(other.max_warnings),
            report_unused_disable_directives: self
                .report_unused_disable_directives
                .or(other.report_unused_disable_directives),
            respect_eslint_disable_directives: self
                .respect_eslint_disable_directives
                .or(other.respect_eslint_disable_directives),
        }
    }
}

/// Oxlint Configuration File
///
/// This configuration is aligned with ESLint v8's configuration schema (`eslintrc.json`).
///
/// Usage: `oxlint -c oxlintrc.json`
///
/// Example
///
/// `.oxlintrc.json`
///
/// ```json
/// {
///   "$schema": "./node_modules/oxlint/configuration_schema.json",
///   "plugins": ["import", "typescript", "unicorn"],
///   "env": {
///     "browser": true
///   },
///   "globals": {
///     "foo": "readonly"
///   },
///   "settings": {
///     "react": {
///       "version": "18.2.0"
///     },
///     "custom": { "option": true }
///   },
///   "rules": {
///     "eqeqeq": "warn",
///     "import/no-cycle": "error",
///     "react/self-closing-comp": ["error", { "html": false }]
///   },
///   "overrides": [
///     {
///       "files": ["*.test.ts", "*.spec.ts"],
///       "rules": {
///         "@typescript-eslint/no-explicit-any": "off"
///       }
///     }
///   ]
///  }
/// ```
///
/// `oxlint.config.ts`
///
/// ```ts
/// import { defineConfig } from "oxlint";
///
/// export default defineConfig({
///   plugins: ["import", "typescript", "unicorn"],
///   env: {
///     "browser": true
///   },
///   globals: {
///     "foo": "readonly"
///   },
///   settings: {
///     react: {
///       version: "18.2.0"
///     },
///     custom: { option: true }
///   },
///   rules: {
///     "eqeqeq": "warn",
///     "import/no-cycle": "error",
///     "react/self-closing-comp": ["error", { "html": false }]
///   },
///   overrides: [
///     {
///       files: ["*.test.ts", "*.spec.ts"],
///       rules: {
///         "@typescript-eslint/no-explicit-any": "off"
///       }
///     }
///   ]
/// });
/// ```
#[derive(Debug, Default, Clone, Deserialize, Serialize, JsonSchema)]
#[serde(default, deny_unknown_fields)]
#[non_exhaustive]
pub struct Oxlintrc {
    /// Schema URI for editor tooling.
    #[serde(rename = "$schema", default, skip_serializing_if = "Option::is_none")]
    pub schema: Option<String>,
    /// Enabled built-in plugins for Oxlint.
    /// You can view the list of available plugins on
    /// [the website](https://oxc.rs/docs/guide/usage/linter/plugins.html#supported-plugins).
    ///
    /// NOTE: Setting the `plugins` field will overwrite the base set of plugins.
    /// The `plugins` array should reflect all of the plugins you want to use.
    pub plugins: Option<LintPlugins>,
    /// JS plugins, allows usage of ESLint plugins with Oxlint.
    ///
    /// Read more about JS plugins in
    /// [the docs](https://oxc.rs/docs/guide/usage/linter/js-plugins.html).
    ///
    /// Note: JS plugins are in alpha and not subject to semver.
    ///
    /// Examples:
    ///
    /// Basic usage with a local plugin path.
    ///
    /// ```json
    /// {
    ///   "jsPlugins": ["./custom-plugin.js"],
    ///   "rules": {
    ///     "custom/rule-name": "warn"
    ///   }
    /// }
    /// ```
    ///
    /// Basic usage with a TypeScript plugin and a local plugin path.
    ///
    /// TypeScript plugin files are supported in the following environments:
    /// - Deno and Bun: TypeScript files are supported natively.
    /// - Node.js >=22.18.0 and Node.js ^20.19.0: TypeScript files are supported natively with built-in
    ///   type-stripping enabled by default.
    ///
    /// For older Node.js versions, TypeScript plugins are not supported. Please use JavaScript plugins or upgrade your Node version.
    ///
    /// ```json
    /// {
    ///   "jsPlugins": ["./custom-plugin.ts"],
    ///   "rules": {
    ///     "custom/rule-name": "warn"
    ///   }
    /// }
    /// ```
    ///
    /// Using a built-in Rust plugin alongside a JS plugin with the same name
    /// by giving the JS plugin an alias.
    ///
    /// ```json
    /// {
    ///   "plugins": ["import"],
    ///   "jsPlugins": [
    ///     { "name": "import-js", "specifier": "eslint-plugin-import" }
    ///   ],
    ///   "rules": {
    ///     "import/no-cycle": "error",
    ///     "import-js/no-unresolved": "warn"
    ///   }
    /// }
    /// ```
    #[serde(rename = "jsPlugins", default, skip_serializing_if = "Option::is_none")]
    #[schemars(schema_with = "external_plugins_schema")]
    pub external_plugins: Option<FxHashSet<ExternalPluginEntry>>,
    pub categories: OxlintCategories,
    /// Example
    ///
    /// `.oxlintrc.json`
    ///
    /// ```json
    /// {
    ///   "$schema": "./node_modules/oxlint/configuration_schema.json",
    ///   "rules": {
    ///     "eqeqeq": "warn",
    ///     "import/no-cycle": "error",
    ///     "prefer-const": ["error", { "ignoreReadBeforeAssign": true }]
    ///   }
    ///  }
    /// ```
    ///
    /// See [Oxlint Rules](https://oxc.rs/docs/guide/usage/linter/rules.html) for the list of
    /// rules.
    pub rules: OxlintRules,
    /// Plugin-specific configuration for both built-in and custom plugins.
    /// This includes settings for built-in plugins such as `react` and `jsdoc`
    /// as well as configuring settings for JS custom plugins loaded via `jsPlugins`.
    pub settings: OxlintSettings,
    /// Environments enable and disable collections of global variables.
    pub env: OxlintEnv,
    /// Enabled or disabled specific global variables.
    pub globals: OxlintGlobals,
    /// Add, remove, or otherwise reconfigure rules for specific files or groups of files.
    #[serde(skip_serializing_if = "OxlintOverrides::is_empty")]
    pub overrides: OxlintOverrides,
    /// Oxlint config options.
    #[serde(skip_serializing_if = "OxlintOptions::is_empty")]
    pub options: OxlintOptions,
    /// Absolute path to the configuration file.
    #[serde(skip)]
    pub path: PathBuf,
    /// Globs to ignore during linting. Patterns use gitignore-style matching,
    /// rooted at the directory containing the configuration file.
    /// Files outside that directory cannot be matched; patterns containing `..`
    /// are rejected as a configuration error.
    #[serde(rename = "ignorePatterns")]
    pub ignore_patterns: Vec<String>,
    /// Paths of configuration files that this configuration file extends (inherits from). The files
    /// are resolved relative to the location of the configuration file that contains the `extends`
    /// property. The configuration files are merged from the first to the last, with the last file
    /// overriding the previous ones.
    #[serde(skip_serializing_if = "Vec::is_empty")]
    pub extends: Vec<PathBuf>,
    /// Extended configuration objects provided via `oxlint.config.ts`.
    ///
    /// This field is not deserialized from JSON config files and is not part of the public
    /// configuration schema. It is populated by the JS config loader when `extends` contains
    /// objects (imported configs).
    #[serde(skip)]
    #[schemars(skip)]
    pub extends_configs: Vec<Oxlintrc>,
}

impl Oxlintrc {
    /// # Errors
    ///
    /// * Parse Failure
    pub fn from_file(path: &Path) -> Result<Self, OxcDiagnostic> {
        let mut string = read_to_string(path).map_err(|e| {
            OxcDiagnostic::error(format!(
                "Failed to parse config {} with error {e:?}",
                path.display()
            ))
        })?;

        // jsonc support
        json_strip_comments::strip(&mut string).map_err(|err| {
            OxcDiagnostic::error(format!("Failed to parse jsonc file {}: {err:?}", path.display()))
        })?;

        let json = serde_json::from_str::<serde_json::Value>(&string).map_err(|err| {
            let ext = path.extension().and_then(OsStr::to_str);
            let err = match ext {
                // syntax error
                Some(ext) if is_json_ext(ext) => err.to_string(),
                Some(_) => "Only JSON configuration files are supported".to_string(),
                None => {
                    format!(
                        "{err}, if the configuration is not a JSON file, please use JSON instead."
                    )
                }
            };
            OxcDiagnostic::error(format!(
                "Failed to parse oxlint config {}.\n{err}",
                path.display()
            ))
        })?;

        if !json.is_object() {
            return Err(OxcDiagnostic::error(format!(
                "Failed to parse oxlint config {}.\nExpected a JSON object.",
                path.display()
            )));
        }

        let mut config = Self::deserialize(&json).map_err(|err| {
            OxcDiagnostic::error(format!("Failed to parse config with error {err:?}"))
        })?;

        config.path = path.to_path_buf();

        #[expect(clippy::missing_panics_doc)]
        let config_dir =
            config.path.parent().expect("config path should have a parent directory").to_path_buf();
        config.set_config_dir(&config_dir);

        Ok(config)
    }

    /// Returns the directory containing this configuration file.
    ///
    /// Returns `None` when the configuration was not loaded from a file (`path` is empty),
    /// e.g. when no configuration file was found.
    pub fn dir(&self) -> Option<&Path> {
        self.path.parent()
    }

    /// # Errors
    ///
    /// * Parse Failure
    pub fn from_string(json_string: &str) -> Result<Self, OxcDiagnostic> {
        let json = serde_json::from_str::<serde_json::Value>(json_string)
            .unwrap_or(serde_json::Value::Null);

        if !json.is_object() {
            return Err(OxcDiagnostic::error(
                "Failed to parse config: expected a JSON object.".to_string(),
            ));
        }

        Self::deserialize(&json).map_err(|err| {
            OxcDiagnostic::error(format!("Failed to parse config with error {err:?}"))
        })
    }

    /// Merges two [Oxlintrc] files together.
    ///
    /// [Self] takes priority over `other` - if both configs define the same property,
    /// the value from [Self] wins.
    ///
    /// For example, if `self` has `{ "rules": { "no-console": "error" } }` and `other` has
    /// `{ "rules": { "no-console": "warn", "no-debugger": "error" } }`, the result will be
    /// `{ "rules": { "no-console": "error", "no-debugger": "error" } }` (self's `"no-console"`
    /// setting wins).
    #[must_use]
    pub fn merge(&self, other: Oxlintrc) -> Oxlintrc {
        let mut categories = other.categories;
        categories.extend(self.categories.iter());

        let rules = self
            .rules
            .rules
            .iter()
            .chain(&other.rules.rules)
            .fold(FxHashMap::default(), |mut rules_set, rule| {
                if rules_set.contains_key(&(&rule.plugin_name, &rule.rule_name)) {
                    return rules_set;
                }
                rules_set.insert((&rule.plugin_name, &rule.rule_name), rule);
                rules_set
            })
            .values()
            .map(|rule| (**rule).clone())
            .collect::<Vec<_>>();

        let settings = self.settings.clone();
        let env = self.env.clone();
        let globals = self.globals.clone();

        let mut overrides = other.overrides;
        overrides.extend(self.overrides.clone());

        let plugins = match (self.plugins, other.plugins) {
            (Some(self_plugins), Some(other_plugins)) => Some(self_plugins | other_plugins),
            (Some(self_plugins), None) => Some(self_plugins | LintPlugins::default()),
            (None, Some(other_plugins)) => Some(other_plugins | LintPlugins::default()),
            (None, None) => None,
        };

        let external_plugins = match (&self.external_plugins, &other.external_plugins) {
            (Some(self_external), Some(other_external)) => {
                Some(self_external.iter().chain(other_external.iter()).cloned().collect())
            }
            (Some(self_external), None) => Some(self_external.clone()),
            (None, Some(other_external)) => Some(other_external.clone()),
            (None, None) => None,
        };

        let schema = self.schema.clone().or(other.schema);
        let options = self.options.merge(&other.options);

        Oxlintrc {
            schema,
            plugins,
            external_plugins,
            categories,
            rules: OxlintRules::new(rules),
            settings,
            env,
            globals,
            overrides,
            options,
            path: self.path.clone(),
            ignore_patterns: self.ignore_patterns.clone(),
            extends: self.extends.clone(),
            extends_configs: self.extends_configs.clone(),
        }
    }

    /// Update the configuration directory for all external plugin entries in this config
    /// and in its overrides. The underlying `HashSet` is rebuilt because `config_dir`
    /// participates in the `Hash`/`Eq` implementation of each entry, so changing it
    /// requires rehashing the set.
    pub fn set_config_dir(&mut self, config_dir: &Path) {
        if let Some(external_plugins) = &mut self.external_plugins {
            *external_plugins = std::mem::take(external_plugins)
                .into_iter()
                .map(|mut entry| {
                    entry.config_dir = config_dir.to_path_buf();
                    entry
                })
                .collect();
        }

        for override_config in self.overrides.iter_mut() {
            if let Some(external_plugins) = &mut override_config.external_plugins {
                *external_plugins = std::mem::take(external_plugins)
                    .into_iter()
                    .map(|mut entry| {
                        entry.config_dir = config_dir.to_path_buf();
                        entry
                    })
                    .collect();
            }
        }

        for config in &mut self.extends_configs {
            config.set_config_dir(config_dir);
        }
    }
}

fn is_json_ext(ext: &str) -> bool {
    ext == "json" || ext == "jsonc"
}

#[cfg(test)]
mod test {
    use rustc_hash::FxHashSet;
    use serde_json::json;

    use crate::config::{external_plugins::ExternalPluginEntry, plugins::LintPlugins};

    use super::*;

    #[test]
    fn test_oxlintrc_de_empty() {
        let config: Oxlintrc = serde_json::from_value(json!({})).unwrap();
        assert_eq!(config.plugins, None);
        assert_eq!(config.rules, OxlintRules::default());
        assert!(config.rules.is_empty());
        assert_eq!(config.settings, OxlintSettings::default());
        assert_eq!(config.env, OxlintEnv::default());
        assert_eq!(config.path, PathBuf::default());
        assert_eq!(config.extends, Vec::<PathBuf>::default());
        assert_eq!(config.options.type_aware, None);
        assert_eq!(config.options.type_check, None);
        assert_eq!(config.options.deny_warnings, None);
        assert_eq!(config.options.max_warnings, None);
        assert_eq!(config.options.report_unused_disable_directives, None);
        assert_eq!(config.options.respect_eslint_disable_directives, None);
    }

    #[test]
    fn test_oxlintrc_reject_non_object() {
        // A non-object top-level config must be rejected, not silently deserialized positionally.
        assert!(Oxlintrc::from_string("[]").is_err());
        assert!(Oxlintrc::from_string(r#"["error"]"#).is_err());
        assert!(Oxlintrc::from_string(r#""foo""#).is_err());
        assert!(Oxlintrc::from_string("42").is_err());
        assert!(Oxlintrc::from_string("true").is_err());
        assert!(Oxlintrc::from_string("null").is_err());
        // A valid object still parses.
        assert!(Oxlintrc::from_string("{}").is_ok());
    }

    #[test]
    fn test_oxlintrc_options_deserialize() {
        let config: Oxlintrc =
            serde_json::from_value(json!({ "options": { "typeAware": true } })).unwrap();
        assert_eq!(config.options.type_aware, Some(true));

        let config: Oxlintrc =
            serde_json::from_value(json!({ "options": { "typeAware": false } })).unwrap();
        assert_eq!(config.options.type_aware, Some(false));

        let config: Oxlintrc =
            serde_json::from_value(json!({ "options": { "typeCheck": true } })).unwrap();
        assert_eq!(config.options.type_check, Some(true));

        let config: Oxlintrc =
            serde_json::from_value(json!({ "options": { "typeCheck": false } })).unwrap();
        assert_eq!(config.options.type_check, Some(false));

        let config: Oxlintrc =
            serde_json::from_value(json!({ "options": { "denyWarnings": true } })).unwrap();
        assert_eq!(config.options.deny_warnings, Some(true));

        let config: Oxlintrc =
            serde_json::from_value(json!({ "options": { "denyWarnings": false } })).unwrap();
        assert_eq!(config.options.deny_warnings, Some(false));

        let config: Oxlintrc =
            serde_json::from_value(json!({ "options": { "maxWarnings": 10 } })).unwrap();
        assert_eq!(config.options.max_warnings, Some(10));

        let config: Result<Oxlintrc, _> =
            serde_json::from_value(json!({ "options": { "maxWarnings": -1 } }));
        assert!(config.is_err());

        let config: Oxlintrc = serde_json::from_value(
            json!({ "options": { "reportUnusedDisableDirectives": "warn" } }),
        )
        .unwrap();
        assert_eq!(config.options.report_unused_disable_directives, Some(AllowWarnDeny::Warn));

        // error and deny

        let config: Oxlintrc = serde_json::from_value(
            json!({ "options": { "reportUnusedDisableDirectives": "error" } }),
        )
        .unwrap();
        assert_eq!(config.options.report_unused_disable_directives, Some(AllowWarnDeny::Deny));

        let config: Oxlintrc = serde_json::from_value(
            json!({ "options": { "reportUnusedDisableDirectives": "deny" } }),
        )
        .unwrap();
        assert_eq!(config.options.report_unused_disable_directives, Some(AllowWarnDeny::Deny));

        // off and allow

        let config: Oxlintrc = serde_json::from_value(
            json!({ "options": { "reportUnusedDisableDirectives": "off" } }),
        )
        .unwrap();
        assert_eq!(config.options.report_unused_disable_directives, Some(AllowWarnDeny::Allow));

        let config: Oxlintrc = serde_json::from_value(
            json!({ "options": { "reportUnusedDisableDirectives": "allow" } }),
        )
        .unwrap();
        assert_eq!(config.options.report_unused_disable_directives, Some(AllowWarnDeny::Allow));

        let config: Oxlintrc = serde_json::from_value(
            json!({ "options": { "respectEslintDisableDirectives": false } }),
        )
        .unwrap();
        assert_eq!(config.options.respect_eslint_disable_directives, Some(false));

        let config: Oxlintrc = serde_json::from_value(
            json!({ "options": { "respectEslintDisableDirectives": true } }),
        )
        .unwrap();
        assert_eq!(config.options.respect_eslint_disable_directives, Some(true));
    }

    #[test]
    fn test_oxlintrc_top_level_options_rejected() {
        let config: Result<Oxlintrc, _> = serde_json::from_value(json!({ "typeAware": true }));
        assert!(config.is_err());

        let config: Result<Oxlintrc, _> = serde_json::from_value(json!({ "typeCheck": true }));
        assert!(config.is_err());

        let config: Result<Oxlintrc, _> = serde_json::from_value(json!({ "denyWarnings": true }));
        assert!(config.is_err());

        let config: Result<Oxlintrc, _> = serde_json::from_value(json!({ "maxWarnings": 1 }));
        assert!(config.is_err());

        let config: Result<Oxlintrc, _> =
            serde_json::from_value(json!({ "reportUnusedDisableDirectives": "warn" }));
        assert!(config.is_err());

        let config: Result<Oxlintrc, _> =
            serde_json::from_value(json!({ "respectEslintDisableDirectives": false }));
        assert!(config.is_err());
    }

    #[test]
    fn test_oxlintrc_merge_options() {
        let mut root: Oxlintrc = serde_json::from_value(
            json!({ "options": { "typeAware": true, "typeCheck": false, "denyWarnings": true, "maxWarnings": 1 } }),
        )
        .unwrap();
        root.path = PathBuf::from("/root/.oxlintrc.json");

        let mut base: Oxlintrc = serde_json::from_value(
            json!({ "options": { "typeAware": false, "typeCheck": true, "denyWarnings": false, "maxWarnings": 10 } }),
        )
        .unwrap();
        base.path = PathBuf::from("/root/base.json");

        let merged = root.merge(base);
        assert_eq!(merged.options.type_aware, Some(true));
        assert_eq!(merged.options.type_check, Some(false));
        assert_eq!(merged.options.deny_warnings, Some(true));
        assert_eq!(merged.options.max_warnings, Some(1));

        // root wins over base for reportUnusedDisableDirectives
        let mut root: Oxlintrc = serde_json::from_value(
            json!({ "options": { "reportUnusedDisableDirectives": "error" } }),
        )
        .unwrap();
        root.path = PathBuf::from("/root/.oxlintrc.json");
        let mut base: Oxlintrc = serde_json::from_value(
            json!({ "options": { "reportUnusedDisableDirectives": "warn" } }),
        )
        .unwrap();
        base.path = PathBuf::from("/root/base.json");
        let merged = root.merge(base);
        assert_eq!(merged.options.report_unused_disable_directives, Some(AllowWarnDeny::Deny));

        // base value propagates when root does not set the field
        let mut root: Oxlintrc = serde_json::from_value(json!({})).unwrap();
        root.path = PathBuf::from("/root/.oxlintrc.json");
        let mut base: Oxlintrc = serde_json::from_value(
            json!({ "options": { "reportUnusedDisableDirectives": "warn" } }),
        )
        .unwrap();
        base.path = PathBuf::from("/root/base.json");
        let merged = root.merge(base);
        assert_eq!(merged.options.report_unused_disable_directives, Some(AllowWarnDeny::Warn));

        // root wins over base for respectEslintDisableDirectives
        let mut root: Oxlintrc = serde_json::from_value(
            json!({ "options": { "respectEslintDisableDirectives": false } }),
        )
        .unwrap();
        root.path = PathBuf::from("/root/.oxlintrc.json");
        let mut base: Oxlintrc = serde_json::from_value(
            json!({ "options": { "respectEslintDisableDirectives": true } }),
        )
        .unwrap();
        base.path = PathBuf::from("/root/base.json");
        let merged = root.merge(base);
        assert_eq!(merged.options.respect_eslint_disable_directives, Some(false));

        // base value propagates when root does not set the field
        let mut root: Oxlintrc = serde_json::from_value(json!({})).unwrap();
        root.path = PathBuf::from("/root/.oxlintrc.json");
        let mut base: Oxlintrc = serde_json::from_value(
            json!({ "options": { "respectEslintDisableDirectives": false } }),
        )
        .unwrap();
        base.path = PathBuf::from("/root/base.json");
        let merged = root.merge(base);
        assert_eq!(merged.options.respect_eslint_disable_directives, Some(false));
    }

    #[test]
    fn test_oxlintrc_de_plugins_empty_array() {
        let config: Oxlintrc = serde_json::from_value(json!({ "plugins": [] })).unwrap();
        assert_eq!(config.plugins, Some(LintPlugins::empty()));
    }

    #[test]
    fn test_oxlintrc_empty_config_plugins() {
        let config: Oxlintrc = serde_json::from_str(r"{}").unwrap();
        assert_eq!(config.plugins, None);
    }

    #[test]
    fn test_oxlintrc_specifying_plugins_will_override() {
        let config: Oxlintrc = serde_json::from_str(r#"{ "plugins": ["react", "oxc"] }"#).unwrap();

        assert_eq!(config.plugins, Some(LintPlugins::REACT | LintPlugins::OXC));
        let config: Oxlintrc =
            serde_json::from_str(r#"{ "plugins": ["typescript", "unicorn"] }"#).unwrap();
        assert_eq!(config.plugins, Some(LintPlugins::TYPESCRIPT | LintPlugins::UNICORN));
        let config: Oxlintrc =
            serde_json::from_str(r#"{ "plugins": ["typescript", "unicorn", "react", "oxc", "import", "jsdoc", "jest", "vitest", "jsx-a11y", "nextjs", "react-perf", "promise", "node", "vue"] }"#).unwrap();
        assert_eq!(config.plugins, Some(LintPlugins::all()));

        let config: Oxlintrc =
            serde_json::from_str(r#"{ "plugins": ["typescript", "@typescript-eslint"] }"#).unwrap();
        assert_eq!(config.plugins, Some(LintPlugins::TYPESCRIPT));
    }

    #[test]
    fn test_oxlintrc_extends() {
        let config: Oxlintrc = serde_json::from_str(
            r#"{"extends": [".oxlintrc.json", "./oxlint-ts.json", "../.config/.oxlintrc.json"]}"#,
        )
        .unwrap();
        assert_eq!(
            config.extends,
            vec![
                PathBuf::from(".oxlintrc.json"),
                PathBuf::from("./oxlint-ts.json"),
                PathBuf::from("../.config/.oxlintrc.json")
            ]
        );

        let config: Oxlintrc = serde_json::from_str(r#"{"extends": []}"#).unwrap();
        assert_eq!(0, config.extends.len());
    }

    #[test]
    fn test_oxlintrc_js_plugins() {
        let config: Oxlintrc = serde_json::from_str(
            r#"{"jsPlugins": ["./plugin.ts", { "name": "custom", "specifier": "./plugin2.ts" }]}"#,
        )
        .unwrap();
        assert_eq!(config.external_plugins.as_ref().unwrap().len(), 2);

        // None
        let config: Oxlintrc = serde_json::from_str(r"{}").unwrap();
        assert!(config.external_plugins.is_none());

        // Empty array
        let config: Oxlintrc = serde_json::from_str(r#"{"jsPlugins": []}"#).unwrap();
        assert_eq!(config.external_plugins.as_ref().unwrap().len(), 0);
    }

    #[test]
    fn test_oxlintrc_js_plugins_rejects_invalid() {
        // Extra fields should be rejected
        assert!(
            serde_json::from_str::<Oxlintrc>(
                r#"{"jsPlugins": [{ "name": "x", "specifier": "y", "extra": "z" }]}"#
            )
            .is_err()
        );

        // Missing required fields should be rejected
        assert!(serde_json::from_str::<Oxlintrc>(r#"{"jsPlugins": [{ "name": "x" }]}"#).is_err());

        // Object with arbitrary field should be rejected
        assert!(
            serde_json::from_str::<Oxlintrc>(r#"{"jsPlugins": [{ "myAlias": "my-plugin" }]}"#)
                .is_err()
        );
    }

    #[test]
    fn test_oxlintrc_js_plugins_roundtrip() {
        let mut config = Oxlintrc::default();
        let mut plugins = FxHashSet::default();
        plugins.insert(ExternalPluginEntry {
            config_dir: PathBuf::default(),
            specifier: "./plugin.ts".to_string(),
            name: None,
        });
        plugins.insert(ExternalPluginEntry {
            config_dir: PathBuf::default(),
            specifier: "./plugin2.ts".to_string(),
            name: Some("custom".to_string()),
        });
        config.external_plugins = Some(plugins);

        let serialized = serde_json::to_string(&config).unwrap();
        let deserialized: Oxlintrc = serde_json::from_str(&serialized).unwrap();
        assert_eq!(config.external_plugins, deserialized.external_plugins);
    }

    #[test]
    fn test_oxlintrc_js_plugins_merge() {
        let config1: Oxlintrc = serde_json::from_str(r#"{"jsPlugins": ["./plugin1.ts"]}"#).unwrap();
        let config2: Oxlintrc = serde_json::from_str(r#"{"jsPlugins": ["./plugin2.ts"]}"#).unwrap();
        let merged = config1.merge(config2);
        assert_eq!(merged.external_plugins.unwrap().len(), 2);
    }

    #[test]
    fn test_oxlintrc_schema_field() {
        // Test that $schema field is accepted and deserialized correctly
        let config: Oxlintrc = serde_json::from_str(
            r#"{
                "$schema": "./node_modules/oxlint/configuration_schema.json",
                "rules": {
                    "no-console": "warn"
                }
            }"#,
        )
        .unwrap();
        assert_eq!(
            config.schema,
            Some("./node_modules/oxlint/configuration_schema.json".to_string())
        );

        // Test that config without $schema still works
        let config_without_schema: Oxlintrc = serde_json::from_str(r#"{"rules": {}}"#).unwrap();
        assert_eq!(config_without_schema.schema, None);

        // Test serialization - $schema should be skipped when None
        let serialized = serde_json::to_string(&config_without_schema).unwrap();
        assert!(!serialized.contains("$schema"));

        // Test merge - self takes priority over other
        let config1: Oxlintrc = serde_json::from_str(r#"{"$schema": "schema1.json"}"#).unwrap();
        let config2: Oxlintrc = serde_json::from_str(r#"{"$schema": "schema2.json"}"#).unwrap();
        let merged = config1.merge(config2);
        assert_eq!(merged.schema, Some("schema1.json".to_string()));

        // Test merge - when self has no schema, use other's schema
        let config1: Oxlintrc = serde_json::from_str(r"{}").unwrap();
        let config2: Oxlintrc = serde_json::from_str(r#"{"$schema": "schema2.json"}"#).unwrap();
        let merged = config1.merge(config2);
        assert_eq!(merged.schema, Some("schema2.json".to_string()));
    }

    #[test]
    fn test_set_config_dir() {
        let mut config: Oxlintrc = serde_json::from_str(
            r#"{
                "jsPlugins": ["./plugin1.ts", { "name": "custom", "specifier": "./plugin2.ts" }],
                "overrides": [{ "files": ["*.test.ts"], "jsPlugins": ["./override-plugin.ts"] }]
            }"#,
        )
        .unwrap();

        // Verify initial state - config_dir should be empty PathBuf
        let top_level_plugins = config.external_plugins.as_ref().unwrap();
        assert_eq!(top_level_plugins.len(), 2);
        for entry in top_level_plugins {
            assert_eq!(entry.config_dir, PathBuf::new());
        }

        let override_plugins =
            config.overrides.iter().next().unwrap().external_plugins.as_ref().unwrap();
        assert_eq!(override_plugins.len(), 1);
        for entry in override_plugins {
            assert_eq!(entry.config_dir, PathBuf::new());
        }

        // Call set_config_dir
        let new_config_dir = PathBuf::from("/project/config");
        config.set_config_dir(&new_config_dir);

        // Assert that all top-level plugins have the new config_dir
        let top_level_plugins = config.external_plugins.as_ref().unwrap();
        assert_eq!(top_level_plugins.len(), 2);
        for entry in top_level_plugins {
            assert_eq!(entry.config_dir, new_config_dir);
        }

        // Assert that all override plugins have the new config_dir
        let override_plugins =
            config.overrides.iter().next().unwrap().external_plugins.as_ref().unwrap();
        assert_eq!(override_plugins.len(), 1);
        for entry in override_plugins {
            assert_eq!(entry.config_dir, new_config_dir);
        }
    }
}
