use crate::ast;
use anyhow::Result;
use oxc_ast::ast::{
    Argument, ArrayExpression, ArrayExpressionElement, AssignmentTarget, BindingPattern,
    ExportDefaultDeclarationKind, Expression, ObjectExpression, ObjectPropertyKind, Program,
    PropertyKey, Statement,
};
use std::collections::{BTreeMap, BTreeSet};
use std::path::{Path, PathBuf};

const DEFAULT_TEST_MATCH: &[&str] = &[
    "**/*.spec.ts",
    "**/*.spec.tsx",
    "**/*.spec.js",
    "**/*.spec.jsx",
    "**/*.spec.mts",
    "**/*.spec.cts",
    "**/*.spec.mjs",
    "**/*.spec.cjs",
    "**/*.test.ts",
    "**/*.test.tsx",
    "**/*.test.js",
    "**/*.test.jsx",
    "**/*.test.mts",
    "**/*.test.cts",
    "**/*.test.mjs",
    "**/*.test.cjs",
];
const DEFAULT_TEST_ID_ATTRIBUTE: &str = "data-testid";

pub struct PlaywrightConfig {
    pub name: Option<String>,
    pub projects: Vec<TestProject>,
}

pub struct TestProject {
    pub config_dir: PathBuf,
    pub test_dir: String,
    pub test_match: Vec<String>,
    pub test_ignore: Vec<String>,
    pub base_url: Option<String>,
    pub test_id_attribute: String,
}

#[derive(Default)]
struct ParsedOptions {
    name: Option<String>,
    test_dir: Option<String>,
    test_match: Option<Vec<String>>,
    test_ignore: Option<Vec<String>>,
    base_url: Option<String>,
    test_id_attribute: Option<String>,
}

impl PlaywrightConfig {
    #[cfg(test)]
    pub fn test_id_attributes(&self) -> Vec<String> {
        let mut attributes: Vec<String> = self
            .projects
            .iter()
            .map(|project| project.test_id_attribute.clone())
            .collect();
        attributes.sort();
        attributes.dedup();
        attributes
    }
}

pub fn load_many(
    root: &Path,
    config_paths: &[PathBuf],
    config_name_filter: Option<&str>,
) -> Result<PlaywrightConfig> {
    if config_paths.is_empty() {
        if let Some(name) = config_name_filter {
            anyhow::bail!("--project requires a named Playwright config, but no config was found matching {name}");
        }
        return Ok(default_config(root));
    }

    let mut configs = Vec::new();
    for config_path in config_paths {
        let config = load(root, config_path)?;
        configs.push((config_path, config));
    }

    validate_config_names(&configs, config_name_filter)?;
    match config_name_filter {
        Some(name)
            if !configs
                .iter()
                .any(|(_, config)| config.name.as_deref() == Some(name)) =>
        {
            return Err(missing_config_name_error(name));
        }
        _ => {}
    }

    let mut projects = Vec::new();
    for (_, config) in configs {
        if config_name_filter.is_some_and(|name| config.name.as_deref() != Some(name)) {
            continue;
        }
        projects.extend(config.projects);
    }

    Ok(PlaywrightConfig {
        name: config_name_filter.map(str::to_string),
        projects,
    })
}

fn missing_config_name_error(name: &str) -> anyhow::Error {
    anyhow::Error::msg(format!("no Playwright config found with name {name}"))
}

impl TestProject {
    pub fn test_dir(&self, root: &Path) -> PathBuf {
        let path = Path::new(&self.test_dir);
        if path.is_absolute() {
            path.to_path_buf()
        } else if self.config_dir.is_absolute() {
            self.config_dir.join(path)
        } else {
            root.join(&self.config_dir).join(path)
        }
    }
}

pub fn load(root: &Path, config_path: &Path) -> Result<PlaywrightConfig> {
    if !config_path.exists() {
        anyhow::bail!(
            "Playwright config does not exist: {}",
            config_path.display()
        );
    }

    let source = std::fs::read_to_string(config_path)?;
    parse_from_path(&source, config_path, config_path.parent().unwrap_or(root))
}

fn default_config(root: &Path) -> PlaywrightConfig {
    PlaywrightConfig {
        name: None,
        projects: vec![TestProject {
            config_dir: root.to_path_buf(),
            test_dir: ".".to_string(),
            test_match: default_test_match(),
            test_ignore: Vec::new(),
            base_url: None,
            test_id_attribute: DEFAULT_TEST_ID_ATTRIBUTE.to_string(),
        }],
    }
}

#[cfg(test)]
fn parse(source: &str, config_dir: &Path) -> Result<PlaywrightConfig> {
    parse_from_path(source, Path::new("playwright.config.ts"), config_dir)
}

fn parse_from_path(source: &str, path: &Path, config_dir: &Path) -> Result<PlaywrightConfig> {
    ast::with_program(path, source, |program, source| {
        parse_program(program, source, config_dir)
    })?
}

fn parse_program(
    program: &Program<'_>,
    source: &str,
    config_dir: &Path,
) -> Result<PlaywrightConfig> {
    let bindings = top_level_object_bindings(program);
    let Some(root_object) = default_export_object(program) else {
        return Ok(PlaywrightConfig {
            name: None,
            projects: vec![merge_project(config_dir, &ParsedOptions::default(), None)],
        });
    };
    let root_options = parse_options(root_object, source, &bindings)?;
    let project_objects = project_objects(root_object);

    if project_objects.is_empty() {
        return Ok(PlaywrightConfig {
            name: root_options.name.clone(),
            projects: vec![merge_project(config_dir, &root_options, None)],
        });
    }

    let mut projects = Vec::new();
    for project_object in project_objects {
        projects.push(merge_project(
            config_dir,
            &root_options,
            Some(parse_options(project_object, source, &bindings)?),
        ));
    }

    Ok(PlaywrightConfig {
        name: root_options.name,
        projects,
    })
}

fn validate_config_names(
    configs: &[(&PathBuf, PlaywrightConfig)],
    config_name_filter: Option<&str>,
) -> Result<()> {
    if configs.len() <= 1 && config_name_filter.is_none() {
        return Ok(());
    }

    let mut seen = BTreeMap::new();
    for (path, config) in configs {
        let Some(name) = config.name.as_deref() else {
            anyhow::bail!(
                "Playwright config {} must define top-level name when multiple configs are analyzed or --project is used",
                path.display()
            );
        };
        if let Some(previous) = seen.insert(name.to_string(), path.display().to_string()) {
            anyhow::bail!(
                "Playwright config name {name} is duplicated by {} and {}",
                previous,
                path.display()
            );
        }
    }
    Ok(())
}

fn merge_project(
    config_dir: &Path,
    root: &ParsedOptions,
    project: Option<ParsedOptions>,
) -> TestProject {
    let project = project.unwrap_or_default();

    TestProject {
        config_dir: config_dir.to_path_buf(),
        test_dir: project
            .test_dir
            .or_else(|| root.test_dir.clone())
            .unwrap_or_else(|| ".".to_string()),
        test_match: project
            .test_match
            .or_else(|| root.test_match.clone())
            .unwrap_or_else(default_test_match),
        test_ignore: combine(root.test_ignore.clone(), project.test_ignore),
        base_url: project.base_url.or_else(|| root.base_url.clone()),
        test_id_attribute: project
            .test_id_attribute
            .or_else(|| root.test_id_attribute.clone())
            .unwrap_or_else(|| DEFAULT_TEST_ID_ATTRIBUTE.to_string()),
    }
}

fn parse_options(
    object: &ObjectExpression<'_>,
    source: &str,
    bindings: &BTreeMap<String, &Expression<'_>>,
) -> Result<ParsedOptions> {
    let use_object = property_expression(object, "use").and_then(|value| {
        let mut seen = BTreeSet::new();
        expression_config_object(value, bindings, &mut seen)
    });

    Ok(ParsedOptions {
        name: property_expression(object, "name").and_then(|value| optional_string(value, source)),
        test_dir: property_expression(object, "testDir")
            .map(|value| required_string(value, source, "testDir"))
            .transpose()?,
        test_match: property_expression(object, "testMatch")
            .map(|value| required_string_or_array(value, source, "testMatch"))
            .transpose()?,
        test_ignore: property_expression(object, "testIgnore")
            .map(|value| required_string_or_array(value, source, "testIgnore"))
            .transpose()?,
        base_url: use_object
            .and_then(|value| property_expression(value, "baseURL"))
            .or_else(|| property_expression(object, "baseURL"))
            .and_then(|value| optional_string(value, source)),
        test_id_attribute: use_object
            .and_then(|value| property_expression(value, "testIdAttribute"))
            .or_else(|| property_expression(object, "testIdAttribute"))
            .and_then(|value| optional_string(value, source)),
    })
}

fn combine(left: Option<Vec<String>>, right: Option<Vec<String>>) -> Vec<String> {
    let mut values = left.unwrap_or_default();
    values.extend(right.unwrap_or_default());
    values
}

fn default_test_match() -> Vec<String> {
    DEFAULT_TEST_MATCH
        .iter()
        .map(|pattern| pattern.to_string())
        .collect()
}

fn default_export_object<'a>(program: &'a Program<'a>) -> Option<&'a ObjectExpression<'a>> {
    let bindings = top_level_object_bindings(program);

    for statement in &program.body {
        if let Statement::ExportDefaultDeclaration(export) = statement {
            return export_config_object(&export.declaration, &bindings);
        }

        if let Some(object) = commonjs_config_object(statement, &bindings) {
            return Some(object);
        }
    }
    None
}

fn top_level_object_bindings<'a>(program: &'a Program<'a>) -> BTreeMap<String, &'a Expression<'a>> {
    let mut bindings = BTreeMap::new();
    for statement in &program.body {
        let Statement::VariableDeclaration(declaration) = statement else {
            continue;
        };
        for declarator in &declaration.declarations {
            let (Some(name), Some(init)) =
                (binding_identifier_name(&declarator.id), &declarator.init)
            else {
                continue;
            };
            bindings.insert(name.to_string(), init);
        }
    }
    bindings
}

fn binding_identifier_name<'a>(binding: &'a BindingPattern<'a>) -> Option<&'a str> {
    match binding {
        BindingPattern::BindingIdentifier(identifier) => Some(identifier.name.as_str()),
        _ => None,
    }
}

fn export_config_object<'a>(
    export: &'a ExportDefaultDeclarationKind<'a>,
    bindings: &BTreeMap<String, &'a Expression<'a>>,
) -> Option<&'a ObjectExpression<'a>> {
    match export {
        ExportDefaultDeclarationKind::ObjectExpression(object) => Some(object),
        ExportDefaultDeclarationKind::CallExpression(call) => {
            call.arguments.first().and_then(|argument| {
                let mut seen = BTreeSet::new();
                argument_config_object(argument, bindings, &mut seen)
            })
        }
        ExportDefaultDeclarationKind::Identifier(identifier) => {
            let mut seen = BTreeSet::new();
            identifier_config_object(identifier.name.as_str(), bindings, &mut seen)
        }
        ExportDefaultDeclarationKind::ParenthesizedExpression(parenthesized) => {
            let mut seen = BTreeSet::new();
            expression_config_object(&parenthesized.expression, bindings, &mut seen)
        }
        _ => None,
    }
}

fn commonjs_config_object<'a>(
    statement: &'a Statement<'a>,
    bindings: &BTreeMap<String, &'a Expression<'a>>,
) -> Option<&'a ObjectExpression<'a>> {
    let Statement::ExpressionStatement(statement) = statement else {
        return None;
    };
    let Expression::AssignmentExpression(assignment) = &statement.expression else {
        return None;
    };
    if assignment_target_path(&assignment.left)
        .as_deref()
        .is_none_or(|parts| parts != ["module", "exports"])
    {
        return None;
    }
    let mut seen = BTreeSet::new();
    expression_config_object(&assignment.right, bindings, &mut seen)
}

fn assignment_target_path(target: &AssignmentTarget<'_>) -> Option<Vec<String>> {
    match target {
        AssignmentTarget::StaticMemberExpression(member) => {
            let mut parts = ast::expression_path(&member.object)?;
            parts.push(member.property.name.to_string());
            Some(parts)
        }
        _ => None,
    }
}

fn argument_config_object<'a>(
    argument: &'a Argument<'a>,
    bindings: &BTreeMap<String, &'a Expression<'a>>,
    seen: &mut BTreeSet<String>,
) -> Option<&'a ObjectExpression<'a>> {
    match argument {
        Argument::ObjectExpression(object) => Some(object),
        Argument::Identifier(identifier) => {
            identifier_config_object(identifier.name.as_str(), bindings, seen)
        }
        Argument::ParenthesizedExpression(parenthesized) => {
            expression_config_object(&parenthesized.expression, bindings, seen)
        }
        _ => None,
    }
}

fn expression_config_object<'a>(
    expression: &'a Expression<'a>,
    bindings: &BTreeMap<String, &'a Expression<'a>>,
    seen: &mut BTreeSet<String>,
) -> Option<&'a ObjectExpression<'a>> {
    match expression {
        Expression::ObjectExpression(object) => Some(object),
        Expression::Identifier(identifier) => {
            identifier_config_object(identifier.name.as_str(), bindings, seen)
        }
        Expression::CallExpression(call) => call
            .arguments
            .first()
            .and_then(|argument| argument_config_object(argument, bindings, seen)),
        Expression::ParenthesizedExpression(parenthesized) => {
            expression_config_object(&parenthesized.expression, bindings, seen)
        }
        _ => None,
    }
}

fn identifier_config_object<'a>(
    name: &str,
    bindings: &BTreeMap<String, &'a Expression<'a>>,
    seen: &mut BTreeSet<String>,
) -> Option<&'a ObjectExpression<'a>> {
    if !seen.insert(name.to_string()) {
        return None;
    }
    let object = bindings
        .get(name)
        .and_then(|expression| expression_config_object(expression, bindings, seen));
    seen.remove(name);
    object
}

fn property_expression<'a>(
    object: &'a ObjectExpression<'a>,
    name: &str,
) -> Option<&'a Expression<'a>> {
    for property in &object.properties {
        let ObjectPropertyKind::ObjectProperty(property) = property else {
            continue;
        };
        if property.computed || property.method {
            continue;
        }
        if property_key_name(&property.key).as_deref() == Some(name) {
            return Some(&property.value);
        }
    }
    None
}

fn property_key_name(key: &PropertyKey<'_>) -> Option<String> {
    match key {
        PropertyKey::StaticIdentifier(identifier) => Some(identifier.name.to_string()),
        PropertyKey::StringLiteral(literal) => Some(literal.value.to_string()),
        _ => None,
    }
}

fn project_objects<'a>(root: &'a ObjectExpression<'a>) -> Vec<&'a ObjectExpression<'a>> {
    let Some(Expression::ArrayExpression(projects)) = property_expression(root, "projects") else {
        return Vec::new();
    };
    projects
        .elements
        .iter()
        .filter_map(array_element_object)
        .collect()
}

fn array_element_object<'a>(
    element: &'a ArrayExpressionElement<'a>,
) -> Option<&'a ObjectExpression<'a>> {
    match element {
        ArrayExpressionElement::ObjectExpression(object) => Some(object),
        _ => None,
    }
}

fn required_string(expression: &Expression<'_>, source: &str, name: &str) -> Result<String> {
    optional_string(expression, source)
        .ok_or_else(|| anyhow::anyhow!("expected string literal for {name}"))
}

fn optional_string(expression: &Expression<'_>, source: &str) -> Option<String> {
    match expression {
        Expression::StringLiteral(literal) => Some(literal.value.to_string()),
        Expression::TemplateLiteral(template) if template.expressions.is_empty() => {
            Some(ast::template_literal_text(template, source))
        }
        Expression::ParenthesizedExpression(parenthesized) => {
            optional_string(&parenthesized.expression, source)
        }
        _ => None,
    }
}

fn required_string_or_array(
    expression: &Expression<'_>,
    source: &str,
    name: &str,
) -> Result<Vec<String>> {
    if let Some(value) = optional_string(expression, source) {
        return Ok(vec![value]);
    }
    let Some(Expression::ArrayExpression(array)) = parenthesized_expression(expression) else {
        anyhow::bail!("expected string literal or string array for {name}");
    };
    string_array(array, source, name)
}

fn parenthesized_expression<'a>(expression: &'a Expression<'a>) -> Option<&'a Expression<'a>> {
    match expression {
        Expression::ParenthesizedExpression(parenthesized) => {
            parenthesized_expression(&parenthesized.expression)
        }
        _ => Some(expression),
    }
}

fn string_array(array: &ArrayExpression<'_>, source: &str, name: &str) -> Result<Vec<String>> {
    let mut values = Vec::new();
    let mut saw_regex = false;
    for element in &array.elements {
        match element {
            ArrayExpressionElement::StringLiteral(literal) => {
                values.push(literal.value.to_string())
            }
            ArrayExpressionElement::TemplateLiteral(template)
                if template.expressions.is_empty() =>
            {
                values.push(ast::template_literal_text(template, source));
            }
            ArrayExpressionElement::RegExpLiteral(_) => saw_regex = true,
            _ => {}
        }
    }
    if values.is_empty() && saw_regex {
        anyhow::bail!("regular-expression {name} patterns are not supported; use string globs");
    }
    Ok(values)
}

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

    #[test]
    fn parses_test_dir_and_match() {
        let source = fixture_source(&["playwright_config", "test-dir-and-match.ts"]);
        let parsed = parse(&source, Path::new("/repo")).unwrap();
        assert_eq!(parsed.name, None);
        assert_eq!(parsed.projects[0].test_dir, "./tests/e2e");
        assert_eq!(parsed.projects[0].test_match, vec!["**/*.spec.ts"]);
    }

    #[test]
    fn parses_projects_with_inheritance() {
        let source = fixture_source(&["playwright_config", "projects-with-inheritance.ts"]);
        let parsed = parse(&source, Path::new("/repo")).unwrap();
        assert_eq!(parsed.projects.len(), 2);
        assert_eq!(parsed.projects[0].test_dir, "./tests");
        assert_eq!(parsed.projects[0].test_ignore, vec!["**/skip/**"]);
        assert_eq!(
            parsed.projects[0].base_url.as_deref(),
            Some("http://localhost:3000")
        );
        assert_eq!(parsed.projects[0].test_id_attribute, "data-pw");
        assert_eq!(parsed.projects[1].test_dir, "./e2e");
        assert_eq!(parsed.projects[1].test_match, vec!["**/*.pw.ts"]);
        assert_eq!(parsed.projects[1].test_id_attribute, "data-test");
        assert_eq!(parsed.test_id_attributes(), vec!["data-pw", "data-test"]);
    }

    #[test]
    fn parses_top_level_base_url_and_string_ignore() {
        let source = fixture_source(&["playwright_config", "top-level-base-url.ts"]);
        let parsed = parse(&source, Path::new("/repo")).unwrap();
        assert_eq!(
            parsed.projects[0].base_url.as_deref(),
            Some("http://localhost:5173")
        );
        assert_eq!(parsed.projects[0].test_ignore, vec!["**/skip/**"]);
        assert_eq!(parsed.projects[0].test_id_attribute, "data-test-id");
    }

    #[test]
    fn parses_default_export_identifier() {
        let source = fixture_source(&["playwright_config", "default-identifier.ts"]);
        let parsed = parse(&source, Path::new("/repo")).unwrap();
        assert_eq!(parsed.projects[0].test_dir, "./identifier-tests");
        assert_eq!(parsed.projects[0].test_match, vec!["**/*.identifier.ts"]);
        assert_eq!(
            parsed.projects[0].base_url.as_deref(),
            Some("http://localhost:4100")
        );
        assert_eq!(parsed.projects[0].test_id_attribute, "data-identifier");
    }

    #[test]
    fn parses_define_config_identifier_argument() {
        let source = fixture_source(&["playwright_config", "define-config-identifier.ts"]);
        let parsed = parse(&source, Path::new("/repo")).unwrap();
        assert_eq!(parsed.projects[0].test_dir, "./define-config-tests");
        assert_eq!(parsed.projects[0].test_match, vec!["**/*.define-config.ts"]);
    }

    #[test]
    fn parses_commonjs_config_exports() {
        let source = fixture_source(&["playwright_config", "commonjs-object.cjs"]);
        let parsed = parse_from_path(
            &source,
            Path::new("playwright.config.cjs"),
            Path::new("/repo"),
        )
        .unwrap();
        assert_eq!(parsed.projects[0].test_dir, "./commonjs-object-tests");
        assert_eq!(
            parsed.projects[0].test_match,
            vec!["**/*.commonjs-object.js"]
        );

        let source = fixture_source(&["playwright_config", "commonjs-define-config.cjs"]);
        let parsed = parse_from_path(
            &source,
            Path::new("playwright.config.cjs"),
            Path::new("/repo"),
        )
        .unwrap();
        assert_eq!(
            parsed.projects[0].test_dir,
            "./commonjs-define-config-tests"
        );
        assert_eq!(
            parsed.projects[0].test_match,
            vec!["**/*.commonjs-define-config.js"]
        );
        assert_eq!(
            parsed.projects[0].base_url.as_deref(),
            Some("http://localhost:5100")
        );
    }

    #[test]
    fn resolves_identifier_backed_use_object() {
        let source = fixture_source(&["playwright_config", "use-identifier.ts"]);
        let parsed = parse(&source, Path::new("/repo")).unwrap();
        assert_eq!(parsed.projects[0].test_dir, "./use-identifier-tests");
        assert_eq!(
            parsed.projects[0].base_url.as_deref(),
            Some("http://localhost:6200")
        );
        assert_eq!(parsed.projects[0].test_id_attribute, "data-shared");
    }

    #[test]
    fn cyclic_identifier_configs_fall_back_without_recursing() {
        let source = fixture_source(&["playwright_config", "cyclic-config.ts"]);
        let parsed = parse(&source, Path::new("/repo")).unwrap();
        assert_eq!(parsed.projects[0].test_dir, ".");
    }

    #[test]
    fn template_literals_use_cooked_text() {
        let source = fixture_source(&["playwright_config", "cooked-template.ts"]);
        let parsed = parse(&source, Path::new("/repo")).unwrap();
        assert_eq!(parsed.projects[0].test_dir, r#"tests\e2e"#);
        assert_eq!(parsed.projects[0].test_match, vec![r#"**\/*.spec.ts"#]);
    }

    #[test]
    fn parser_handles_advanced_export_shapes() {
        let source = fixture_source(&["playwright_config", "advanced-export-shapes.ts"]);
        let parsed = parse(&source, Path::new("/repo")).unwrap();
        assert_eq!(parsed.projects[0].test_dir, "./advanced-export-tests");
        assert_eq!(
            parsed.projects[0].test_match,
            vec!["**/*.advanced-export.ts"]
        );

        let source = fixture_source(&["playwright_config", "non-object-binding.ts"]);
        let parsed = parse(&source, Path::new("/repo")).unwrap();
        assert_eq!(parsed.projects[0].test_dir, ".");
    }

    #[test]
    fn ignores_non_literal_optional_playwright_values() {
        let source = fixture_source(&["playwright_config", "nonliteral-optional-values.ts"]);
        let parsed = parse(&source, Path::new("/repo")).unwrap();
        assert_eq!(parsed.projects[0].test_dir, "./tests");
        assert_eq!(parsed.projects[0].base_url, None);
        assert_eq!(parsed.projects[0].test_id_attribute, "data-testid");
    }

    #[test]
    fn load_without_config_uses_default_project() {
        let parsed = load_many(Path::new("/repo"), &[], None).unwrap();
        assert_eq!(parsed.projects[0].test_dir, ".");
        assert!(parsed.projects[0]
            .test_match
            .contains(&"**/*.spec.ts".to_string()));
    }

    #[test]
    fn load_many_without_configs_rejects_project_filter() {
        let err = load_many(Path::new("/repo"), &[], Some("storybook"))
            .err()
            .expect("expected project filter without config to fail");
        assert!(err.to_string().contains("--project requires"));
    }

    #[test]
    fn load_missing_config_errors() {
        let err = load(Path::new("/repo"), Path::new("/repo/missing.ts"))
            .err()
            .expect("expected missing config to fail");
        assert!(err.to_string().contains("does not exist"));
    }

    #[test]
    fn load_many_errors_when_project_filter_matches_no_config() {
        let dir = fixture_path(&["config", "multi-playwright-config"]);
        let config = dir.join("playwright.config.mts");
        let err = load_many(&dir, &[config], Some("missing"))
            .err()
            .expect("expected missing config name to fail");
        assert!(err.to_string().contains("no Playwright config found"));
    }

    #[test]
    fn load_existing_config_reads_and_parses() {
        let dir = fixture_path(&["playwright_config", "load-existing"]);
        let config = dir.join("playwright.config.ts");
        let parsed = load(&dir, &config).unwrap();
        assert_eq!(parsed.projects[0].test_dir, "./tests");
    }

    #[test]
    fn load_directory_config_path_returns_read_error() {
        let dir = fixture_path(&["playwright_config", "load-existing"]);
        let err = load(&dir, &dir)
            .err()
            .expect("expected directory config path to fail");
        assert!(!err.to_string().is_empty());
    }

    #[test]
    fn test_dir_resolves_absolute_relative_and_relative_config_dir() {
        let absolute = TestProject {
            config_dir: PathBuf::from("/repo"),
            test_dir: "/tmp/tests".to_string(),
            test_match: vec![],
            test_ignore: vec![],
            base_url: None,
            test_id_attribute: "data-testid".to_string(),
        };
        assert_eq!(
            absolute.test_dir(Path::new("/repo")),
            PathBuf::from("/tmp/tests")
        );

        let absolute_config_relative_test_dir = TestProject {
            config_dir: PathBuf::from("/repo"),
            test_dir: "tests".to_string(),
            test_match: vec![],
            test_ignore: vec![],
            base_url: None,
            test_id_attribute: "data-testid".to_string(),
        };
        assert_eq!(
            absolute_config_relative_test_dir.test_dir(Path::new("/repo")),
            PathBuf::from("/repo/tests")
        );

        let relative_config = TestProject {
            config_dir: PathBuf::from("config"),
            test_dir: "tests".to_string(),
            test_match: vec![],
            test_ignore: vec![],
            base_url: None,
            test_id_attribute: "data-testid".to_string(),
        };
        assert_eq!(
            relative_config.test_dir(Path::new("/repo")),
            PathBuf::from("/repo/config/tests")
        );
    }

    #[test]
    fn parse_accepts_spaced_property_and_escaped_string() {
        let source = fixture_source(&["playwright_config", "spaced-property.ts"]);
        let parsed = parse(&source, Path::new("/repo")).unwrap();
        assert_eq!(parsed.projects[0].test_dir, r#"tests\e2e"#);
    }

    #[test]
    fn parser_rejects_unsupported_required_values() {
        assert!(parse("export default { testDir: 123 }", Path::new("/repo")).is_err());
        assert!(parse("export default { testIgnore: 123 }", Path::new("/repo")).is_err());
        assert!(parse(
            "export default { testMatch: [/.*\\.spec\\.ts/] }",
            Path::new("/repo")
        )
        .is_err());
        assert!(parse("export default { testMatch: 123 }", Path::new("/repo")).is_err());
        assert!(parse(
            "export default { projects: [{ testDir: 123 }] }",
            Path::new("/repo")
        )
        .is_err());
    }

    #[test]
    fn malformed_projects_value_falls_back_to_single_project() {
        let parsed = parse(
            "export default { projects: makeProjects() }",
            Path::new("/repo"),
        )
        .unwrap();
        assert_eq!(parsed.projects.len(), 1);
    }

    #[test]
    fn root_options_ignore_project_values() {
        let source = fixture_source(&["playwright_config", "project-values-only.ts"]);
        let parsed = parse(&source, Path::new("/repo")).unwrap();
        assert_eq!(parsed.projects[0].test_dir, "./project-tests");
        assert_eq!(parsed.projects[0].test_match, vec!["**/*.project.ts"]);
    }

    #[test]
    fn parser_handles_ast_edge_shapes() {
        let source = fixture_source(&["playwright_config", "no-default-export.ts"]);
        let parsed = parse(&source, Path::new("/repo")).unwrap();
        assert_eq!(parsed.projects[0].test_dir, ".");

        let source = fixture_source(&["playwright_config", "non-object-default.ts"]);
        let parsed = parse(&source, Path::new("/repo")).unwrap();
        assert_eq!(parsed.projects[0].test_dir, ".");

        let source = fixture_source(&["playwright_config", "default-function.ts"]);
        let parsed = parse(&source, Path::new("/repo")).unwrap();
        assert_eq!(parsed.projects[0].test_dir, ".");

        let source = fixture_source(&["playwright_config", "edge-shapes.ts"]);
        let parsed = parse(&source, Path::new("/repo")).unwrap();
        assert_eq!(parsed.projects.len(), 1);
        assert_eq!(parsed.projects[0].test_dir, "./project-tests");
        assert_eq!(parsed.projects[0].test_match, vec!["**/*.project.ts"]);
        assert_eq!(parsed.projects[0].test_ignore, vec!["**/skip/**"]);
        assert_eq!(
            parsed.projects[0].base_url.as_deref(),
            Some("http://localhost:3000")
        );
        assert_eq!(parsed.projects[0].test_id_attribute, "data-test");
    }
}
