use anyhow::Result;
use jsonc_parser::ParseOptions;
use serde::Deserialize;
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};

const CONFIG_FILE_STEM: &str = ".playwright-ast-coverage";
const CONFIG_EXTENSIONS: &[&str] = &["yaml", "yml", "json", "jsonc"];
const DEFAULT_FRONTEND_ROOT: &str = "app";
const DEFAULT_SELECTOR_ATTRIBUTES: &[&str] = &["data-testid", "data-pw"];
const PLAYWRIGHT_CONFIG_EXTENSIONS: &[&str] = &["ts", "mts", "cts", "js", "mjs", "cjs"];

#[derive(Default, Deserialize)]
#[serde(rename_all = "camelCase", default)]
struct FileConfig {
    frontend_root: Option<String>,
    playwright_config: Option<OneOrMany>,
    test_include: Vec<String>,
    test_exclude: Vec<String>,
    ignore_routes: Vec<String>,
    navigation_helpers: Vec<String>,
    selector_attributes: Option<Vec<String>>,
    component_selector_attributes: BTreeMap<String, String>,
    selector_roots: Option<Vec<String>>,
    selector_include: Vec<String>,
    selector_exclude: Vec<String>,
}

#[derive(Deserialize)]
#[serde(untagged)]
enum OneOrMany {
    One(String),
    Many(Vec<String>),
}

#[derive(Clone)]
pub struct Settings {
    pub frontend_root: String,
    pub playwright_configs: Vec<PathBuf>,
    pub project: Option<String>,
    pub test_include: Vec<String>,
    pub test_exclude: Vec<String>,
    pub ignore_routes: Vec<String>,
    pub navigation_helpers: Vec<String>,
    pub selector_attributes: Vec<String>,
    pub component_selector_attributes: BTreeMap<String, String>,
    pub selector_roots: Vec<String>,
    pub selector_include: Vec<String>,
    pub selector_exclude: Vec<String>,
}

pub fn load_settings(
    root: &Path,
    cli_config: Option<&Path>,
    cli_playwright_configs: &[PathBuf],
    cli_project: Option<String>,
) -> Result<Settings> {
    let file_config = load_file_config(root, cli_config)?;
    let playwright_configs = if !cli_playwright_configs.is_empty() {
        cli_playwright_configs
            .iter()
            .map(|path| resolve(root, path))
            .collect()
    } else if let Some(paths) = file_config.playwright_config.as_ref() {
        paths
            .values()
            .iter()
            .map(|path| resolve(root, Path::new(path)))
            .collect()
    } else {
        find_default_playwright_configs(root)?
    };

    let frontend_root = file_config
        .frontend_root
        .unwrap_or_else(|| DEFAULT_FRONTEND_ROOT.to_string());
    let selector_roots = file_config
        .selector_roots
        .unwrap_or_else(|| vec![frontend_root.clone()]);

    Ok(Settings {
        frontend_root,
        playwright_configs,
        project: cli_project,
        test_include: file_config.test_include,
        test_exclude: file_config.test_exclude,
        ignore_routes: file_config.ignore_routes,
        navigation_helpers: file_config.navigation_helpers,
        selector_attributes: file_config
            .selector_attributes
            .unwrap_or_else(default_selector_attributes),
        component_selector_attributes: file_config.component_selector_attributes,
        selector_roots,
        selector_include: file_config.selector_include,
        selector_exclude: file_config.selector_exclude,
    })
}

impl OneOrMany {
    fn values(&self) -> Vec<String> {
        match self {
            OneOrMany::One(value) => vec![value.clone()],
            OneOrMany::Many(values) => values.clone(),
        }
    }
}

fn load_file_config(root: &Path, cli_config: Option<&Path>) -> Result<FileConfig> {
    let Some(config_path) = config_path(root, cli_config)? else {
        return Ok(FileConfig::default());
    };

    let source = std::fs::read_to_string(&config_path)?;
    parse_file_config(&source, &config_path)
}

fn config_path(root: &Path, cli_config: Option<&Path>) -> Result<Option<PathBuf>> {
    if let Some(path) = cli_config {
        let config_path = resolve(root, path);
        if !config_path.exists() {
            anyhow::bail!("config file does not exist: {}", config_path.display());
        }
        return Ok(Some(config_path));
    }

    let mut configs = Vec::new();
    for extension in CONFIG_EXTENSIONS {
        let path = root.join(format!("{CONFIG_FILE_STEM}.{extension}"));
        if path.exists() {
            configs.push(path);
        }
    }

    match configs.len() {
        0 => Ok(None),
        1 => Ok(configs.pop()),
        _ => {
            let files = configs
                .iter()
                .map(|path| path.display().to_string())
                .collect::<Vec<_>>()
                .join(", ");
            anyhow::bail!("multiple config files found under --root: {files}");
        }
    }
}

fn parse_file_config(source: &str, config_path: &Path) -> Result<FileConfig> {
    match config_extension(config_path) {
        Some("yaml" | "yml") => Ok(serde_yaml::from_str(source)?),
        Some("json") => Ok(serde_json::from_str(source)?),
        Some("jsonc") => Ok(serde_json::from_value(jsonc_parser::parse_to_serde_value(
            source,
            &jsonc_parse_options(),
        )?)?),
        Some(extension) => anyhow::bail!(
            "unsupported config file extension .{extension}; supported extensions are .yaml, .yml, .json, and .jsonc"
        ),
        None => anyhow::bail!(
            "unsupported config file without extension; supported extensions are .yaml, .yml, .json, and .jsonc"
        ),
    }
}

fn config_extension(path: &Path) -> Option<&str> {
    path.extension().and_then(|extension| extension.to_str())
}

fn jsonc_parse_options() -> ParseOptions {
    ParseOptions {
        allow_comments: true,
        allow_loose_object_property_names: false,
        allow_trailing_commas: true,
        allow_missing_commas: false,
        allow_single_quoted_strings: false,
        allow_hexadecimal_numbers: false,
        allow_unary_plus_numbers: false,
    }
}

fn resolve(root: &Path, path: &Path) -> PathBuf {
    if path.is_absolute() {
        path.to_path_buf()
    } else {
        root.join(path)
    }
}

fn find_default_playwright_configs(root: &Path) -> Result<Vec<PathBuf>> {
    if !root.exists() {
        return Ok(Vec::new());
    }

    let mut configs = Vec::new();
    for entry in std::fs::read_dir(root)? {
        let entry = entry?;
        let path = entry.path();
        if !path.is_file() || !is_playwright_config_name(&path) {
            continue;
        }
        configs.push(path);
    }
    configs.sort();
    Ok(configs)
}

fn is_playwright_config_name(path: &Path) -> bool {
    let Some(name) = path.file_name().and_then(|name| name.to_str()) else {
        return false;
    };
    let Some(extension) = path.extension().and_then(|extension| extension.to_str()) else {
        return false;
    };

    name.starts_with("playwright")
        && name.contains(".config.")
        && PLAYWRIGHT_CONFIG_EXTENSIONS.contains(&extension)
}

fn default_selector_attributes() -> Vec<String> {
    DEFAULT_SELECTOR_ATTRIBUTES
        .iter()
        .map(|attribute| attribute.to_string())
        .collect()
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::test_support::fixture_path;

    #[test]
    fn missing_default_config_uses_defaults() {
        let root = fixture_path(&["config", "missing-default"]);
        let settings = load_settings(&root, None, &[], None).unwrap();
        assert_eq!(settings.frontend_root, "app");
        assert!(settings.playwright_configs.is_empty());
        assert_eq!(settings.selector_attributes, vec!["data-testid", "data-pw"]);
        assert!(settings.component_selector_attributes.is_empty());
        assert_eq!(settings.selector_roots, vec!["app"]);
    }

    #[test]
    fn explicit_missing_config_errors() {
        let root = fixture_path(&["config", "missing-default"]);
        let err = load_settings(&root, Some(Path::new("missing.yaml")), &[], None)
            .err()
            .expect("expected missing config to fail");
        assert!(err.to_string().contains("config file does not exist"));
    }

    #[test]
    fn explicit_unsupported_config_extension_errors() {
        let root = fixture_path(&["config", "unsupported-extension"]);
        let err = load_settings(
            &root,
            Some(Path::new(".playwright-ast-coverage.toml")),
            &[],
            None,
        )
        .err()
        .expect("expected unsupported config extension to fail");
        assert!(err
            .to_string()
            .contains("unsupported config file extension"));
    }

    #[test]
    fn explicit_extensionless_config_errors() {
        let root = fixture_path(&["config", "extensionless"]);
        let err = load_settings(
            &root,
            Some(Path::new(".playwright-ast-coverage")),
            &[],
            None,
        )
        .err()
        .expect("expected extensionless config to fail");
        assert!(err
            .to_string()
            .contains("unsupported config file without extension"));
    }

    #[test]
    fn reads_yaml_and_finds_default_playwright_config() {
        let root = fixture_path(&["config", "full"]);
        let settings = load_settings(&root, None, &[], None).unwrap();
        assert_eq!(settings.frontend_root, "web/app");
        assert_eq!(settings.test_exclude, vec!["**/skip/**"]);
        assert_eq!(settings.navigation_helpers, vec!["navigateTo"]);
        assert_eq!(settings.selector_roots, vec!["web/components"]);
        assert_eq!(settings.selector_include, vec!["web/components/**/*.tsx"]);
        assert_eq!(settings.selector_exclude, vec!["**/*.test.tsx"]);
        assert_eq!(
            settings.playwright_configs,
            vec![root.join("playwright.config.mts")]
        );
    }

    #[test]
    fn reads_yml_default_config() {
        let root = fixture_path(&["config", "yml"]);
        let settings = load_settings(&root, None, &[], None).unwrap();
        assert_eq!(settings.frontend_root, "web/yml-app");
    }

    #[test]
    fn reads_json_default_config() {
        let root = fixture_path(&["config", "json"]);
        let settings = load_settings(&root, None, &[], None).unwrap();
        assert_eq!(settings.frontend_root, "web/json-app");
        assert_eq!(settings.navigation_helpers, vec!["openJsonPath"]);
    }

    #[test]
    fn reads_jsonc_default_config() {
        let root = fixture_path(&["config", "jsonc"]);
        let settings = load_settings(&root, None, &[], None).unwrap();
        assert_eq!(settings.frontend_root, "web/jsonc-app");
        assert_eq!(settings.test_exclude, vec!["**/jsonc-skip/**"]);
    }

    #[test]
    fn jsonc_config_rejects_json5_object_keys() {
        let root = fixture_path(&["config", "invalid-jsonc"]);
        let err = load_settings(&root, None, &[], None)
            .err()
            .expect("expected invalid JSONC to fail");
        assert!(err
            .to_string()
            .contains("Expected string for object property"));
    }

    #[test]
    fn duplicate_default_config_files_error() {
        let root = fixture_path(&["config", "duplicate-defaults"]);
        let err = load_settings(&root, None, &[], None)
            .err()
            .expect("expected duplicate default configs to fail");
        assert!(err.to_string().contains("multiple config files found"));
        assert!(err.to_string().contains(".playwright-ast-coverage.yaml"));
        assert!(err.to_string().contains(".playwright-ast-coverage.json"));
    }

    #[test]
    fn yaml_playwright_config_path_is_resolved() {
        let root = fixture_path(&["config", "yaml-playwright-config"]);
        let settings = load_settings(&root, None, &[], None).unwrap();
        assert_eq!(
            settings.playwright_configs,
            vec![root.join("configs/playwright.config.ts")]
        );
    }

    #[test]
    fn cli_playwright_config_absolute_path_is_preserved() {
        let root = fixture_path(&["config", "missing-default"]);
        let config = root.join("custom.config.ts");
        let settings = load_settings(&root, None, std::slice::from_ref(&config), None).unwrap();
        assert_eq!(settings.playwright_configs, vec![config]);
    }

    #[test]
    fn invalid_yaml_errors() {
        let root = fixture_path(&["config", "invalid-yaml"]);
        let err = load_settings(&root, None, &[], None)
            .err()
            .expect("expected invalid YAML to fail");
        assert!(!err.to_string().is_empty());
    }

    #[test]
    fn selector_attributes_can_be_custom_or_disabled() {
        let custom = fixture_path(&["config", "selector-attributes-custom"]);
        let settings = load_settings(&custom, None, &[], None).unwrap();
        assert_eq!(
            settings.selector_attributes,
            vec!["data-test", "data-test-id"]
        );
        assert_eq!(
            settings
                .component_selector_attributes
                .get("dataTest")
                .map(String::as_str),
            Some("data-test")
        );

        let disabled = fixture_path(&["config", "selector-attributes-disabled"]);
        let settings = load_settings(&disabled, None, &[], None).unwrap();
        assert!(settings.selector_attributes.is_empty());
        assert!(settings.component_selector_attributes.is_empty());
    }

    #[test]
    fn old_default_config_name_is_ignored() {
        let root = fixture_path(&["config", "old-name"]);
        let settings = load_settings(&root, None, &[], None).unwrap();
        assert_eq!(settings.frontend_root, "app");
    }

    #[test]
    fn default_discovery_finds_all_root_playwright_configs() {
        let root = fixture_path(&["config", "multi-playwright-config"]);
        let settings = load_settings(&root, None, &[], None).unwrap();
        assert_eq!(
            settings.playwright_configs,
            vec![
                root.join("playwright.config.mts"),
                root.join("playwright.storybook.config.mts"),
            ]
        );
    }

    #[test]
    fn yaml_playwright_config_array_paths_are_resolved() {
        let root = fixture_path(&["config", "yaml-playwright-config-array"]);
        let settings = load_settings(&root, None, &[], None).unwrap();
        assert_eq!(
            settings.playwright_configs,
            vec![
                root.join("playwright.config.mts"),
                root.join("playwright.storybook.config.mts"),
            ]
        );
    }

    #[test]
    fn playwright_config_name_filter_rejects_paths_without_file_or_extension() {
        assert!(!is_playwright_config_name(Path::new("")));
        assert!(!is_playwright_config_name(Path::new("playwright")));
        assert!(!is_playwright_config_name(Path::new("playwright.config")));
    }
}
