mod ast;
mod config;
mod matcher;
mod playwright_config;
mod playwright_tests;
mod playwright_urls;
mod routes;
mod selectors;
#[cfg(test)]
mod test_support;

#[cfg(not(test))]
use anyhow::Context;
use anyhow::Result;
use clap::{Parser, Subcommand};
use config::Settings;
use globset::{GlobBuilder, GlobSet, GlobSetBuilder};
use rayon::prelude::*;
use routes::Route;
use serde::Serialize;
use std::collections::{BTreeMap, BTreeSet, HashMap};
use std::path::{Path, PathBuf};
#[cfg(not(test))]
use std::process::ExitCode;
use walkdir::WalkDir;

#[derive(Parser)]
#[command(author, version, about)]
struct Cli {
    #[arg(long, default_value = ".", global = true)]
    root: PathBuf,

    #[arg(long, global = true)]
    config: Option<PathBuf>,

    #[arg(long, global = true)]
    playwright_config: Vec<PathBuf>,

    #[arg(long, global = true)]
    project: Option<String>,

    #[arg(long, global = true)]
    json: bool,

    #[arg(long, global = true)]
    assert_conditional_tests: bool,

    #[arg(long, global = true)]
    allow_skipped_tests: bool,

    #[arg(long, global = true)]
    assert_unique_selectors: bool,

    #[command(subcommand)]
    command: Command,
}

#[derive(Subcommand)]
enum Command {
    Check,
    Edges,
    Related {
        #[arg(required = true, num_args = 1..)]
        files: Vec<PathBuf>,
    },
}

#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct Summary {
    total_routes: usize,
    covered_routes: usize,
    uncovered_routes: usize,
    total_selectors: usize,
    covered_selectors: usize,
    uncovered_selectors: usize,
    duplicate_selectors: usize,
}

#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct CoverageRoute {
    route: String,
    file: String,
    covered: bool,
    tests: Vec<String>,
    urls: Vec<String>,
}

#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct CoverageSelector {
    attribute: String,
    value: String,
    file: String,
    covered: bool,
    unsupported_dynamic: bool,
    tests: Vec<String>,
    selectors: Vec<String>,
}

#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct DuplicateSelector {
    attribute: String,
    value: String,
    file: String,
}

#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct CoverageReport {
    summary: Summary,
    routes: Vec<CoverageRoute>,
    selectors: Vec<CoverageSelector>,
    duplicate_selectors: Vec<DuplicateSelector>,
}

#[derive(Eq, PartialEq, Ord, PartialOrd, Serialize)]
#[serde(tag = "kind", rename_all = "camelCase")]
enum Edge {
    #[serde(rename_all = "camelCase")]
    Route {
        test_file: String,
        route_file: String,
        route: String,
        url: String,
    },
    #[serde(rename_all = "camelCase")]
    Selector {
        test_file: String,
        app_file: String,
        attribute: String,
        value: String,
        selector: String,
    },
}

#[derive(Serialize)]
struct EdgeReport {
    edges: Vec<Edge>,
}

#[derive(Serialize)]
struct RelatedReport {
    tests: Vec<String>,
}

struct Analysis {
    coverage: CoverageReport,
    edges: EdgeReport,
}

struct RouteTarget {
    route_file: String,
    pattern: String,
    segments: Vec<String>,
}

struct AppSelectorTarget<'a> {
    selector: &'a selectors::AppSelector,
    app_file: String,
    value: String,
}

#[derive(Clone, Eq, PartialEq, Ord, PartialOrd)]
struct TestProjectContext {
    base_url: Option<String>,
    test_id_attribute: String,
}

struct DiscoveredTestFile {
    path: PathBuf,
    contexts: Vec<TestProjectContext>,
}

struct TestProjectDiscovery {
    context: TestProjectContext,
    test_dir: PathBuf,
    include: GlobSet,
    ignore: GlobSet,
}

struct TestAnalysisContext<'a> {
    root: &'a Path,
    route_index: &'a RouteIndex,
    app_selector_targets: &'a [AppSelectorTarget<'a>],
    selector_index: &'a SelectorIndex<'a>,
    navigation_helpers: &'a [String],
    selector_regexes: &'a selectors::SelectorRegexes,
    test_policy: playwright_tests::TestPolicy,
}

#[derive(Default)]
struct RouteIndex {
    root: Vec<RouteTarget>,
    literal_first: HashMap<String, Vec<RouteTarget>>,
    dynamic_first: Vec<RouteTarget>,
}

#[derive(Default)]
struct SelectorIndex<'a> {
    exact: HashMap<String, HashMap<String, Vec<&'a AppSelectorTarget<'a>>>>,
    by_attribute: HashMap<String, Vec<&'a AppSelectorTarget<'a>>>,
    templates_by_attribute: HashMap<String, Vec<&'a AppSelectorTarget<'a>>>,
}

type SelectorCoverageKey = (String, String, String);
type CoverageLinks = (BTreeSet<String>, BTreeSet<String>);

impl TestProjectContext {
    fn from_project(project: &playwright_config::TestProject) -> Self {
        Self {
            base_url: project.base_url.clone(),
            test_id_attribute: project.test_id_attribute.clone(),
        }
    }
}

impl DiscoveredTestFile {
    fn base_urls(&self) -> Vec<String> {
        let mut urls: Vec<String> = self
            .contexts
            .iter()
            .filter_map(|context| context.base_url.clone())
            .collect();
        urls.sort();
        urls.dedup();
        urls
    }

    fn test_id_attributes(&self) -> Vec<String> {
        let mut attributes: Vec<String> = self
            .contexts
            .iter()
            .map(|context| context.test_id_attribute.clone())
            .collect();
        attributes.sort();
        attributes.dedup();
        attributes
    }
}

#[cfg(not(test))]
fn main() -> ExitCode {
    match run() {
        Ok(code) => code,
        Err(err) => {
            eprintln!("error: {err:#}");
            ExitCode::from(2)
        }
    }
}

#[cfg(not(test))]
fn run() -> Result<ExitCode> {
    let cli = Cli::parse();
    let root = absolutize(&cli.root).context("failed to resolve --root")?;
    let settings = config::load_settings(
        &root,
        cli.config.as_deref(),
        &cli.playwright_config,
        cli.project.clone(),
    )?;
    let analysis = analyze_with_policy(
        &root,
        &settings,
        playwright_tests::TestPolicy {
            assert_conditional_tests: cli.assert_conditional_tests,
            allow_skipped_tests: cli.allow_skipped_tests,
        },
        cli.assert_unique_selectors,
    )?;
    match cli.command {
        Command::Check => {
            if cli.json {
                println!("{}", serde_json::to_string_pretty(&analysis.coverage)?);
            } else {
                print_coverage_text(&analysis.coverage);
            }
            if analysis.coverage.summary.uncovered_routes > 0
                || analysis.coverage.summary.uncovered_selectors > 0
                || analysis.coverage.summary.duplicate_selectors > 0
            {
                Ok(ExitCode::from(1))
            } else {
                Ok(ExitCode::SUCCESS)
            }
        }
        Command::Edges => {
            if cli.json {
                println!("{}", serde_json::to_string_pretty(&analysis.edges)?);
            } else {
                print_edges_text(&analysis.edges);
            }
            Ok(ExitCode::SUCCESS)
        }
        Command::Related { files } => {
            let related = build_related_report(&root, &analysis.edges.edges, &files);
            if cli.json {
                println!("{}", serde_json::to_string_pretty(&related)?);
            } else {
                print_related_text(&related);
            }
            Ok(ExitCode::SUCCESS)
        }
    }
}

#[cfg(test)]
fn analyze(root: &Path, settings: &Settings) -> Result<Analysis> {
    analyze_with_policy(
        root,
        settings,
        playwright_tests::TestPolicy::default(),
        false,
    )
}

fn analyze_with_policy(
    root: &Path,
    settings: &Settings,
    test_policy: playwright_tests::TestPolicy,
    assert_unique_selectors: bool,
) -> Result<Analysis> {
    let route_root = root.join(&settings.frontend_root);
    let routes = routes::collect_routes(&route_root)?;
    if routes.is_empty() {
        anyhow::bail!(
            "no Next.js page routes found under {}",
            route_root
                .strip_prefix(root)
                .unwrap_or(&route_root)
                .display()
        );
    }

    let playwright = playwright_config::load_many(
        root,
        &settings.playwright_configs,
        settings.project.as_deref(),
    )?;
    let test_files = discover_test_files(root, settings, &playwright)?;
    let selector_regexes = selectors::compile_selector_regexes(
        &settings.selector_attributes,
        &settings.component_selector_attributes,
    );
    let app_selector_occurrences = if settings.selector_attributes.is_empty()
        && settings.component_selector_attributes.is_empty()
    {
        Vec::new()
    } else {
        collect_app_selector_occurrences(root, settings, &selector_regexes)?
    };
    let mut app_selectors = app_selector_occurrences.clone();
    app_selectors.sort();
    app_selectors.dedup();
    let route_index = route_index(root, &routes);
    let app_selector_targets = app_selector_targets(root, &app_selectors);
    let selector_index = selector_index(&app_selector_targets);
    let test_analysis = TestAnalysisContext {
        root,
        route_index: &route_index,
        app_selector_targets: &app_selector_targets,
        selector_index: &selector_index,
        navigation_helpers: &settings.navigation_helpers,
        selector_regexes: &selector_regexes,
        test_policy,
    };

    let mut edges: Vec<Edge> = test_files
        .par_iter()
        .try_fold(Vec::new, |mut edges, test_file| -> Result<_> {
            edges.extend(analyze_test_file(test_file, &test_analysis)?);
            Ok(edges)
        })
        .try_reduce(Vec::new, |mut left, mut right| -> Result<_> {
            left.append(&mut right);
            Ok(left)
        })?;
    edges.sort();
    edges.dedup();

    let edge_report = EdgeReport { edges };
    let coverage = build_coverage(
        root,
        &routes,
        &app_selectors,
        &app_selector_occurrences,
        &edge_report.edges,
        settings,
        assert_unique_selectors,
    );
    Ok(Analysis {
        coverage,
        edges: edge_report,
    })
}

fn analyze_test_file(
    test_file: &DiscoveredTestFile,
    context: &TestAnalysisContext<'_>,
) -> Result<Vec<Edge>> {
    let source = std::fs::read_to_string(&test_file.path)?;
    let rel_test_file = relative_string(context.root, &test_file.path);
    let mut edges = Vec::new();
    let base_urls = test_file.base_urls();
    let test_id_attributes = test_file.test_id_attributes();

    let (raw_urls, playwright_selectors) =
        ast::with_program(&test_file.path, &source, |program, source| {
            let raw_urls = playwright_urls::extract_playwright_url_occurrences_from_program(
                program,
                source,
                context.navigation_helpers,
            );
            let playwright_selectors = if context.app_selector_targets.is_empty() {
                Vec::new()
            } else {
                selectors::extract_playwright_selector_occurrences_from_program(
                    program,
                    source,
                    context.selector_regexes,
                    &test_id_attributes,
                )
            };
            (raw_urls, playwright_selectors)
        })?;

    for raw_url in raw_urls {
        if !context.test_policy.allows(raw_url.status) {
            continue;
        }
        let Some(url) = normalize_url(&raw_url.value, &base_urls) else {
            continue;
        };
        let ref_segments = matcher::reference_segments(&url);
        let matching_routes: Vec<&RouteTarget> = context
            .route_index
            .candidates(&ref_segments)
            .into_iter()
            .filter(|route| matcher::matches_segments(&ref_segments, &route.segments))
            .collect();
        let Some(best_specificity) = matching_routes
            .iter()
            .map(|route| route_specificity(&route.segments))
            .max()
        else {
            continue;
        };
        for route in matching_routes
            .into_iter()
            .filter(|route| route_specificity(&route.segments) == best_specificity)
        {
            edges.push(Edge::Route {
                test_file: rel_test_file.clone(),
                route_file: route.route_file.clone(),
                route: route.pattern.clone(),
                url: url.clone(),
            });
        }
    }

    if !context.app_selector_targets.is_empty() {
        for playwright_selector in &playwright_selectors {
            if !context.test_policy.allows(playwright_selector.status) {
                continue;
            }
            for app_selector in context.selector_index.matches(&playwright_selector.value) {
                edges.push(Edge::Selector {
                    test_file: rel_test_file.clone(),
                    app_file: app_selector.app_file.clone(),
                    attribute: app_selector.selector.attribute.clone(),
                    value: app_selector.value.clone(),
                    selector: playwright_selector.value.selector.clone(),
                });
            }
        }
    }

    Ok(edges)
}

fn route_index(root: &Path, routes: &[Route]) -> RouteIndex {
    let mut index = RouteIndex::default();
    for route in routes {
        let target = RouteTarget {
            route_file: relative_string(root, &route.file),
            pattern: route.pattern.clone(),
            segments: matcher::pattern_segments(&route.pattern)
                .into_iter()
                .map(str::to_string)
                .collect(),
        };
        match target.segments.first() {
            None => index.root.push(target),
            Some(first) if is_dynamic_pattern_segment(first) => index.dynamic_first.push(target),
            Some(first) => index
                .literal_first
                .entry(first.clone())
                .or_default()
                .push(target),
        }
    }
    index
}

fn app_selector_targets<'a>(
    root: &Path,
    app_selectors: &'a [selectors::AppSelector],
) -> Vec<AppSelectorTarget<'a>> {
    app_selectors
        .iter()
        .map(|selector| AppSelectorTarget {
            selector,
            app_file: relative_string(root, &selector.file),
            value: selector.display_value(),
        })
        .collect()
}

fn selector_index<'a>(targets: &'a [AppSelectorTarget<'a>]) -> SelectorIndex<'a> {
    let mut index = SelectorIndex::default();
    for target in targets {
        if target.selector.unsupported_dynamic() {
            continue;
        }
        index
            .by_attribute
            .entry(target.selector.attribute.clone())
            .or_default()
            .push(target);
        if let selectors::AppSelectorValue::Exact(value) = &target.selector.value {
            index
                .exact
                .entry(target.selector.attribute.clone())
                .or_default()
                .entry(value.clone())
                .or_default()
                .push(target);
        }
        if matches!(
            target.selector.value,
            selectors::AppSelectorValue::Template(_)
        ) {
            index
                .templates_by_attribute
                .entry(target.selector.attribute.clone())
                .or_default()
                .push(target);
        }
    }
    index
}

impl RouteIndex {
    fn candidates<'a>(&'a self, reference_segments: &[&str]) -> Vec<&'a RouteTarget> {
        if reference_segments.is_empty() {
            return self.root.iter().chain(&self.dynamic_first).collect();
        }

        let mut candidates: Vec<&RouteTarget> = self.dynamic_first.iter().collect();
        if let Some(literal) = self.literal_first.get(reference_segments[0]) {
            candidates.extend(literal);
        }
        candidates
    }
}

impl<'a> SelectorIndex<'a> {
    fn matches(
        &'a self,
        playwright_selector: &selectors::PlaywrightSelector,
    ) -> Vec<&'a AppSelectorTarget<'a>> {
        let mut matches = Vec::new();
        if let Some(value) = playwright_selector.exact_value() {
            if let Some(by_value) = self.exact.get(&playwright_selector.attribute) {
                if let Some(exact) = by_value.get(value) {
                    matches.extend(exact.iter().copied());
                }
            }
            let Some(attribute_targets) = self
                .templates_by_attribute
                .get(&playwright_selector.attribute)
            else {
                return matches;
            };
            for target in attribute_targets {
                if target.selector.matches_playwright(playwright_selector) {
                    matches.push(*target);
                }
            }
            return matches;
        }

        if let Some(attribute_targets) = self.by_attribute.get(&playwright_selector.attribute) {
            for target in attribute_targets {
                if target.selector.matches_playwright(playwright_selector) {
                    matches.push(*target);
                }
            }
        }
        matches
    }
}

fn route_specificity(segments: &[String]) -> Vec<u8> {
    let mut specificity: Vec<u8> = segments
        .iter()
        .map(|segment| {
            if segment == "**" {
                0
            } else if segment == "*" {
                1
            } else if segment.starts_with(':') {
                2
            } else {
                3
            }
        })
        .collect();
    specificity.push(4);
    specificity
}

#[cfg(test)]
fn collect_app_selectors(
    root: &Path,
    settings: &Settings,
    selector_regexes: &selectors::SelectorRegexes,
) -> Result<Vec<selectors::AppSelector>> {
    let mut app_selectors = collect_app_selector_occurrences(root, settings, selector_regexes)?;
    app_selectors.sort();
    app_selectors.dedup();

    Ok(app_selectors)
}

fn collect_app_selector_occurrences(
    root: &Path,
    settings: &Settings,
    selector_regexes: &selectors::SelectorRegexes,
) -> Result<Vec<selectors::AppSelector>> {
    let include = build_globset(&settings.selector_include)?;
    let exclude = build_globset(&settings.selector_exclude)?;
    let include_all = settings.selector_include.is_empty();
    let source_files =
        collect_selector_source_files(root, settings, &include, &exclude, include_all);
    let app_selectors = source_files
        .par_iter()
        .try_fold(Vec::new, |mut app_selectors, path| -> Result<_> {
            let source = std::fs::read_to_string(path)?;
            app_selectors.extend(selectors::extract_app_selectors_with_regexes(
                path,
                &source,
                selector_regexes,
            )?);
            Ok(app_selectors)
        })
        .try_reduce(Vec::new, |mut left, mut right| -> Result<_> {
            left.append(&mut right);
            Ok(left)
        })?;
    Ok(app_selectors)
}

fn collect_selector_source_files(
    root: &Path,
    settings: &Settings,
    include: &GlobSet,
    exclude: &GlobSet,
    include_all: bool,
) -> Vec<PathBuf> {
    let mut source_files = BTreeSet::new();
    for selector_root in &settings.selector_roots {
        let source_root = root.join(selector_root);
        if !source_root.exists() {
            continue;
        }

        for path in walk_files(&source_root) {
            if !selectors::is_source_file(&path) {
                continue;
            }
            let rel = relative_string(root, &path);
            if (!include_all && !include.is_match(&rel)) || exclude.is_match(&rel) {
                continue;
            }

            source_files.insert(path);
        }
    }

    source_files.into_iter().collect()
}

fn discover_test_files(
    root: &Path,
    settings: &Settings,
    playwright: &playwright_config::PlaywrightConfig,
) -> Result<Vec<DiscoveredTestFile>> {
    let project_discovery = build_project_discovery(root, playwright)?;
    let all_contexts = test_project_contexts(&project_discovery);
    if !settings.test_include.is_empty() {
        let include = build_globset(&settings.test_include)?;
        let exclude = build_globset(&settings.test_exclude)?;
        let mut files = Vec::new();
        for path in walk_files(root).into_iter().filter(|path| {
            let rel = relative_string(root, path);
            include.is_match(&rel) && !exclude.is_match(&rel)
        }) {
            let mut contexts = matching_project_contexts(root, &project_discovery, &path);
            if contexts.is_empty() {
                contexts = all_contexts.clone();
            }
            files.push(DiscoveredTestFile { contexts, path });
        }
        return Ok(files);
    }

    let yaml_exclude = build_globset(&settings.test_exclude)?;
    let mut files: BTreeMap<PathBuf, BTreeSet<TestProjectContext>> = BTreeMap::new();
    let mut projects_by_test_dir: HashMap<PathBuf, Vec<&TestProjectDiscovery>> = HashMap::new();

    for project_discovery in &project_discovery {
        if !project_discovery.test_dir.exists() {
            continue;
        }
        projects_by_test_dir
            .entry(project_discovery.test_dir.clone())
            .or_default()
            .push(project_discovery);
    }

    for (test_dir, projects) in projects_by_test_dir {
        for path in walk_files(&test_dir) {
            let rel_root = relative_string(root, &path);
            if yaml_exclude.is_match(&rel_root) {
                continue;
            }
            let rel_test = relative_string(&test_dir, &path);
            let abs = slash_path(&path);
            let mut contexts_to_add = Vec::new();
            for project_discovery in &projects {
                let included = project_discovery.include.is_match(&rel_root)
                    || project_discovery.include.is_match(&rel_test)
                    || project_discovery.include.is_match(&abs);
                let ignored = project_discovery.ignore.is_match(&rel_root)
                    || project_discovery.ignore.is_match(&rel_test)
                    || project_discovery.ignore.is_match(&abs);
                if included && !ignored {
                    contexts_to_add.push(project_discovery.context.clone());
                }
            }
            if !contexts_to_add.is_empty() {
                files.entry(path).or_default().extend(contexts_to_add);
            }
        }
    }

    Ok(files
        .into_iter()
        .map(|(path, contexts)| DiscoveredTestFile {
            path,
            contexts: contexts.into_iter().collect(),
        })
        .collect())
}

fn build_project_discovery(
    root: &Path,
    playwright: &playwright_config::PlaywrightConfig,
) -> Result<Vec<TestProjectDiscovery>> {
    let mut discovery = Vec::new();
    for project in &playwright.projects {
        discovery.push(TestProjectDiscovery {
            context: TestProjectContext::from_project(project),
            test_dir: project.test_dir(root),
            include: build_globset(&project.test_match)?,
            ignore: build_globset(&project.test_ignore)?,
        });
    }
    Ok(discovery)
}

fn test_project_contexts(projects: &[TestProjectDiscovery]) -> Vec<TestProjectContext> {
    let mut contexts: Vec<TestProjectContext> = projects
        .iter()
        .map(|project| project.context.clone())
        .collect();
    contexts.sort();
    contexts.dedup();
    contexts
}

fn matching_project_contexts(
    root: &Path,
    projects: &[TestProjectDiscovery],
    path: &Path,
) -> Vec<TestProjectContext> {
    let rel_root = relative_string(root, path);
    let mut contexts = BTreeSet::new();
    for project in projects {
        if !path.starts_with(&project.test_dir) {
            continue;
        }

        let rel_test = relative_string(&project.test_dir, path);
        let abs = slash_path(path);
        let included = project.include.is_match(&rel_root)
            || project.include.is_match(&rel_test)
            || project.include.is_match(&abs);
        let ignored = project.ignore.is_match(&rel_root)
            || project.ignore.is_match(&rel_test)
            || project.ignore.is_match(&abs);
        if included && !ignored {
            contexts.insert(project.context.clone());
        }
    }
    contexts.into_iter().collect()
}

fn build_coverage(
    root: &Path,
    routes: &[Route],
    app_selectors: &[selectors::AppSelector],
    app_selector_occurrences: &[selectors::AppSelector],
    edges: &[Edge],
    settings: &Settings,
    assert_unique_selectors: bool,
) -> CoverageReport {
    let ignored: Vec<String> = settings.ignore_routes.clone();
    let mut by_route: BTreeMap<&str, (BTreeSet<String>, BTreeSet<String>)> = BTreeMap::new();
    let mut by_selector: BTreeMap<SelectorCoverageKey, CoverageLinks> = BTreeMap::new();

    for edge in edges {
        match edge {
            Edge::Route {
                test_file,
                route,
                url,
                ..
            } => {
                let entry = by_route
                    .entry(route.as_str())
                    .or_insert_with(|| (BTreeSet::new(), BTreeSet::new()));
                entry.0.insert(test_file.clone());
                entry.1.insert(url.clone());
            }
            Edge::Selector {
                test_file,
                app_file,
                attribute,
                value,
                selector,
            } => {
                let entry = by_selector
                    .entry((app_file.clone(), attribute.clone(), value.clone()))
                    .or_insert_with(|| (BTreeSet::new(), BTreeSet::new()));
                entry.0.insert(test_file.clone());
                entry.1.insert(selector.clone());
            }
        }
    }

    let mut coverage_routes = Vec::new();
    for route in routes {
        let (tests, urls) = by_route
            .get(route.pattern.as_str())
            .cloned()
            .unwrap_or_default();
        let covered = !tests.is_empty() || is_ignored(&route.pattern, &ignored);
        coverage_routes.push(CoverageRoute {
            route: route.pattern.clone(),
            file: relative_string(root, &route.file),
            covered,
            tests: tests.into_iter().collect(),
            urls: urls.into_iter().collect(),
        });
    }

    coverage_routes.sort_by(|a, b| a.route.cmp(&b.route).then_with(|| a.file.cmp(&b.file)));
    let mut coverage_selectors = Vec::new();
    for app_selector in app_selectors {
        let app_file = relative_string(root, &app_selector.file);
        let value = app_selector.display_value();
        let (tests, selectors) = by_selector
            .get(&(
                app_file.clone(),
                app_selector.attribute.clone(),
                value.clone(),
            ))
            .cloned()
            .unwrap_or_default();
        let covered = !tests.is_empty();
        coverage_selectors.push(CoverageSelector {
            attribute: app_selector.attribute.clone(),
            value,
            file: app_file,
            covered,
            unsupported_dynamic: app_selector.unsupported_dynamic(),
            tests: tests.into_iter().collect(),
            selectors: selectors.into_iter().collect(),
        });
    }
    coverage_selectors.sort_by(|a, b| {
        a.attribute
            .cmp(&b.attribute)
            .then_with(|| a.value.cmp(&b.value))
            .then_with(|| a.file.cmp(&b.file))
    });

    let total_routes = coverage_routes.len();
    let covered_routes = coverage_routes.iter().filter(|route| route.covered).count();
    let uncovered_routes = total_routes.saturating_sub(covered_routes);
    let total_selectors = coverage_selectors.len();
    let covered_selectors = coverage_selectors
        .iter()
        .filter(|selector| selector.covered)
        .count();
    let uncovered_selectors = total_selectors.saturating_sub(covered_selectors);
    let duplicate_selectors = if assert_unique_selectors {
        build_duplicate_selectors(root, app_selector_occurrences)
    } else {
        Vec::new()
    };
    let duplicate_selector_count = duplicate_selectors.len();

    CoverageReport {
        summary: Summary {
            total_routes,
            covered_routes,
            uncovered_routes,
            total_selectors,
            covered_selectors,
            uncovered_selectors,
            duplicate_selectors: duplicate_selector_count,
        },
        routes: coverage_routes,
        selectors: coverage_selectors,
        duplicate_selectors,
    }
}

fn build_duplicate_selectors(
    root: &Path,
    app_selectors: &[selectors::AppSelector],
) -> Vec<DuplicateSelector> {
    let mut by_value: BTreeMap<&str, Vec<&selectors::AppSelector>> = BTreeMap::new();
    for selector in app_selectors {
        if let selectors::AppSelectorValue::Exact(value) = &selector.value {
            by_value.entry(value.as_str()).or_default().push(selector);
        }
    }

    let mut duplicates = Vec::new();
    for (value, selectors) in by_value {
        if selectors.len() < 2 {
            continue;
        }
        for selector in selectors {
            duplicates.push(DuplicateSelector {
                attribute: selector.attribute.clone(),
                value: value.to_string(),
                file: relative_string(root, &selector.file),
            });
        }
    }
    duplicates.sort_by(|a, b| {
        a.value
            .cmp(&b.value)
            .then_with(|| a.file.cmp(&b.file))
            .then_with(|| a.attribute.cmp(&b.attribute))
    });
    duplicates
}

fn print_coverage_text(report: &CoverageReport) {
    println!("Routes: {}", report.summary.total_routes);
    println!("Covered routes: {}", report.summary.covered_routes);
    println!("Uncovered routes: {}", report.summary.uncovered_routes);
    println!("Selectors: {}", report.summary.total_selectors);
    println!("Covered selectors: {}", report.summary.covered_selectors);
    println!(
        "Uncovered selectors: {}",
        report.summary.uncovered_selectors
    );
    println!(
        "Duplicate selectors: {}",
        report.summary.duplicate_selectors
    );

    if report.summary.uncovered_routes == 0
        && report.summary.uncovered_selectors == 0
        && report.summary.duplicate_selectors == 0
    {
        println!();
        println!("All routes and selectors covered.");
        return;
    }

    if report.summary.uncovered_routes > 0 {
        println!();
        println!("Uncovered routes:");
        for route in report.routes.iter().filter(|route| !route.covered) {
            println!("  {}  {}", route.route, route.file);
        }
    }

    if report.summary.uncovered_selectors > 0 {
        println!();
        println!("Uncovered selectors:");
        for selector in report.selectors.iter().filter(|selector| !selector.covered) {
            println!(
                "  [{}=\"{}\"]  {}",
                selector.attribute, selector.value, selector.file
            );
        }
    }

    if report.summary.duplicate_selectors > 0 {
        println!();
        println!("Duplicate selectors:");
        for selector in &report.duplicate_selectors {
            println!(
                "  [{}=\"{}\"]  {}",
                selector.attribute, selector.value, selector.file
            );
        }
    }
}

fn print_edges_text(report: &EdgeReport) {
    for edge in &report.edges {
        match edge {
            Edge::Route {
                test_file,
                route_file,
                route,
                url,
            } => println!("{test_file} -> {route_file} ({route}, {url})"),
            Edge::Selector {
                test_file,
                app_file,
                attribute,
                value,
                selector,
            } => println!("{test_file} -> {app_file} ([{attribute}=\"{value}\"], {selector})"),
        }
    }
}

fn build_related_report(root: &Path, edges: &[Edge], files: &[PathBuf]) -> RelatedReport {
    let related_files: BTreeSet<String> = files
        .iter()
        .map(|file| related_input_file(root, file))
        .collect();
    let mut tests = BTreeSet::new();

    for edge in edges {
        match edge {
            Edge::Route {
                test_file,
                route_file,
                ..
            } if related_files.contains(route_file) => {
                tests.insert(test_file.clone());
            }
            Edge::Selector {
                test_file,
                app_file,
                ..
            } if related_files.contains(app_file) => {
                tests.insert(test_file.clone());
            }
            _ => {}
        }
    }

    RelatedReport {
        tests: tests.into_iter().collect(),
    }
}

fn related_input_file(root: &Path, file: &Path) -> String {
    if file.is_absolute() {
        return relative_string(root, file);
    }

    let rooted = root.join(file);
    relative_string(root, &rooted)
}

fn print_related_text(report: &RelatedReport) {
    for test in &report.tests {
        println!("{test}");
    }
}

fn normalize_url(raw: &str, base_urls: &[String]) -> Option<String> {
    if raw.starts_with("//") {
        return None;
    }

    if raw.starts_with('/') {
        return Some(raw.to_string());
    }

    for base_url in base_urls {
        let base = base_url.trim_end_matches('/');
        if let Some(rest) = raw.strip_prefix(base) {
            if rest.is_empty() {
                return Some("/".to_string());
            }
            if rest.starts_with('/') {
                return Some(rest.to_string());
            }
        }
    }

    None
}

fn is_dynamic_pattern_segment(segment: &str) -> bool {
    segment.starts_with(':') || segment == "*" || segment == "**"
}

fn is_ignored(route: &str, ignored: &[String]) -> bool {
    ignored
        .iter()
        .any(|pattern| route == pattern || matcher::matches(route, pattern))
}

fn build_globset(patterns: &[String]) -> Result<GlobSet> {
    let mut builder = GlobSetBuilder::new();
    for pattern in patterns {
        let glob = GlobBuilder::new(pattern).literal_separator(false).build()?;
        builder.add(glob);
    }
    Ok(builder.build()?)
}

fn walk_files(root: &Path) -> Vec<PathBuf> {
    let mut files: Vec<PathBuf> = WalkDir::new(root)
        .into_iter()
        .filter_entry(|entry| !is_skipped_dir(entry.path()))
        .filter_map(|entry| entry.ok())
        .filter(|entry| entry.file_type().is_file())
        .map(|entry| entry.into_path())
        .collect();
    files.sort();
    files
}

fn is_skipped_dir(path: &Path) -> bool {
    path.file_name()
        .and_then(|name| name.to_str())
        .is_some_and(|name| {
            matches!(
                name,
                ".git" | "node_modules" | "target" | "dist" | "build" | "coverage" | "test-results"
            )
        })
}

fn relative_string(root: &Path, path: &Path) -> String {
    slash_path(path.strip_prefix(root).unwrap_or(path))
}

fn slash_path(path: &Path) -> String {
    path.to_string_lossy().replace('\\', "/")
}

fn absolutize(path: &Path) -> Result<PathBuf> {
    if path.is_absolute() {
        Ok(path.to_path_buf())
    } else {
        Ok(std::env::current_dir()?.join(path))
    }
}

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

    #[test]
    fn normalize_url_handles_relative_absolute_base_and_external() {
        let bases = vec!["http://localhost:3000/".to_string()];
        assert_eq!(
            normalize_url("/users/42", &bases),
            Some("/users/42".to_string())
        );
        assert_eq!(
            normalize_url("http://localhost:3000/users/42", &bases),
            Some("/users/42".to_string())
        );
        assert_eq!(
            normalize_url("http://localhost:3000", &bases),
            Some("/".to_string())
        );
        assert_eq!(normalize_url("http://localhost:3000x", &bases), None);
        assert_eq!(normalize_url("https://example.com/users/42", &bases), None);
    }

    #[test]
    fn ignore_routes_match_exact_and_dynamic_patterns() {
        assert!(is_ignored("/settings", &["/settings".to_string()]));
        assert!(is_ignored("/users/42", &["/users/:id".to_string()]));
        assert!(!is_ignored("/admin", &["/settings".to_string()]));
    }

    #[test]
    fn skipped_directories_are_detected() {
        assert!(is_skipped_dir(Path::new("node_modules")));
        assert!(!is_skipped_dir(Path::new("src")));
    }

    #[test]
    fn path_helpers_handle_absolute_and_relative_paths() {
        let cwd = std::env::current_dir().unwrap();
        assert_eq!(
            absolutize(Path::new("/tmp")).unwrap(),
            PathBuf::from("/tmp")
        );
        assert_eq!(absolutize(Path::new(".")).unwrap(), cwd.join("."));
        assert_eq!(
            relative_string(Path::new("/repo"), Path::new("/other/file.ts")),
            "/other/file.ts"
        );
    }

    #[test]
    fn compiled_route_matching_handles_edge_segments() {
        assert!(is_dynamic_pattern_segment(":id"));
        assert!(is_dynamic_pattern_segment("*"));
        assert!(is_dynamic_pattern_segment("**"));
        assert!(!is_dynamic_pattern_segment("users"));

        assert_eq!(
            matcher::reference_segments("/users/42/?tab=profile"),
            vec!["users", "42"]
        );
        assert_eq!(
            matcher::pattern_segments("/users/:id"),
            vec!["users", ":id"]
        );
        assert!(matcher::matches_segments(
            &["shop"],
            &["shop".to_string(), "**".to_string()]
        ));
        assert!(!matcher::matches_segments(
            &["shop"],
            &["shop".to_string(), "item".to_string()]
        ));
    }

    #[test]
    fn route_specificity_prefers_earlier_static_segments_and_exact_end() {
        let foo_dynamic: Vec<String> = matcher::pattern_segments("/foo/:id")
            .into_iter()
            .map(str::to_string)
            .collect();
        let dynamic_bar: Vec<String> = matcher::pattern_segments("/:section/bar")
            .into_iter()
            .map(str::to_string)
            .collect();
        let docs_exact: Vec<String> = matcher::pattern_segments("/docs")
            .into_iter()
            .map(str::to_string)
            .collect();
        let docs_catch_all: Vec<String> = matcher::pattern_segments("/docs/**")
            .into_iter()
            .map(str::to_string)
            .collect();

        assert!(route_specificity(&foo_dynamic) > route_specificity(&dynamic_bar));
        assert!(route_specificity(&docs_exact) > route_specificity(&docs_catch_all));
    }

    #[test]
    fn selector_index_matches_exact_template_and_fuzzy_selectors() {
        let root = Path::new("/repo");
        let app_selectors = selectors::extract_app_selectors(
            Path::new("/repo/web/app/page.tsx"),
            r#"
                export function Page({ id }) {
                    return <>
                        <button data-testid="save-button" />
                        <div data-testid={`user-${id}`} />
                        <span data-pw="other" />
                    </>;
                }
            "#,
            &["data-testid".to_string(), "data-pw".to_string()],
            &BTreeMap::new(),
        )
        .unwrap();
        let targets = app_selector_targets(root, &app_selectors);
        let index = selector_index(&targets);

        let exact = selectors::extract_playwright_selectors(
            "await page.getByTestId('user-123');",
            &["data-testid".to_string()],
            &["data-testid".to_string()],
        );
        assert_eq!(index.matches(&exact[0]).len(), 1);

        let fuzzy = selectors::extract_playwright_selectors(
            r#"await page.locator('[data-testid^="save"]');"#,
            &["data-testid".to_string()],
            &["data-testid".to_string()],
        );
        assert_eq!(index.matches(&fuzzy[0]).len(), 1);

        let missing_value = selectors::extract_playwright_selectors(
            r#"await page.locator('[data-testid^="missing"]');"#,
            &["data-testid".to_string()],
            &["data-testid".to_string()],
        );
        assert!(index.matches(&missing_value[0]).is_empty());

        let missing_attribute = selectors::extract_playwright_selectors(
            r#"await page.locator('[data-role^="save"]');"#,
            &["data-role".to_string()],
            &["data-role".to_string()],
        );
        assert!(index.matches(&missing_attribute[0]).is_empty());
    }

    #[test]
    fn build_globset_rejects_invalid_patterns() {
        assert!(build_globset(&["[".to_string()]).is_err());
    }

    #[test]
    fn walk_files_returns_files_and_skips_configured_directories() {
        let root = fixture_path(&["main", "walk-files"]);
        let files: Vec<String> = walk_files(&root)
            .into_iter()
            .map(|path| relative_string(&root, &path))
            .collect();
        assert_eq!(files, vec!["src/a.ts", "src/b.ts"]);
    }

    #[test]
    fn collect_app_selectors_skips_missing_roots_and_non_source_files() {
        let root = fixture_path(&["main", "selector-source"]);
        let settings = Settings {
            frontend_root: "web/app".to_string(),
            playwright_configs: vec![],
            project: None,
            test_include: vec![],
            test_exclude: vec![],
            ignore_routes: vec![],
            navigation_helpers: vec![],
            selector_attributes: vec!["data-testid".to_string()],
            component_selector_attributes: BTreeMap::new(),
            selector_roots: vec!["missing".to_string(), "web/app".to_string()],
            selector_include: vec![],
            selector_exclude: vec![],
        };

        let selector_regexes = selectors::compile_selector_regexes(
            &settings.selector_attributes,
            &settings.component_selector_attributes,
        );
        let selectors = collect_app_selectors(&root, &settings, &selector_regexes).unwrap();
        assert_eq!(selectors.len(), 1);
        assert_eq!(selectors[0].display_value(), "save");
    }

    #[test]
    fn coverage_sort_uses_file_as_tiebreaker() {
        let root = Path::new("/repo");
        let routes = vec![
            Route {
                file: PathBuf::from("/repo/web/app/a/page.tsx"),
                pattern: "/same".to_string(),
            },
            Route {
                file: PathBuf::from("/repo/web/app/b/page.tsx"),
                pattern: "/same".to_string(),
            },
        ];
        let settings = Settings {
            frontend_root: "web/app".to_string(),
            playwright_configs: vec![],
            project: None,
            test_include: vec![],
            test_exclude: vec![],
            ignore_routes: vec![],
            navigation_helpers: vec![],
            selector_attributes: vec!["data-testid".to_string(), "data-pw".to_string()],
            component_selector_attributes: BTreeMap::new(),
            selector_roots: vec!["web/app".to_string()],
            selector_include: vec![],
            selector_exclude: vec![],
        };
        let report = build_coverage(root, &routes, &[], &[], &[], &settings, false);
        assert_eq!(report.routes[0].file, "web/app/a/page.tsx");
        assert_eq!(report.routes[1].file, "web/app/b/page.tsx");
    }

    #[test]
    fn selector_coverage_sorts_and_counts_uncovered() {
        let root = Path::new("/repo");
        let app_selectors = vec![selectors::AppSelector {
            file: PathBuf::from("/repo/web/app/page.tsx"),
            attribute: "data-testid".to_string(),
            value: selectors::AppSelectorValue::Exact("save".to_string()),
        }];
        let settings = Settings {
            frontend_root: "web/app".to_string(),
            playwright_configs: vec![],
            project: None,
            test_include: vec![],
            test_exclude: vec![],
            ignore_routes: vec![],
            navigation_helpers: vec![],
            selector_attributes: vec!["data-testid".to_string()],
            component_selector_attributes: BTreeMap::new(),
            selector_roots: vec!["web/app".to_string()],
            selector_include: vec![],
            selector_exclude: vec![],
        };
        let report = build_coverage(
            root,
            &[],
            &app_selectors,
            &app_selectors,
            &[],
            &settings,
            false,
        );
        assert_eq!(report.summary.total_selectors, 1);
        assert_eq!(report.summary.uncovered_selectors, 1);
        assert_eq!(report.selectors[0].file, "web/app/page.tsx");
    }

    #[test]
    fn selector_coverage_sort_uses_value_and_file_tiebreakers() {
        let root = Path::new("/repo");
        let app_selectors = vec![
            selectors::AppSelector {
                file: PathBuf::from("/repo/web/app/b.tsx"),
                attribute: "data-testid".to_string(),
                value: selectors::AppSelectorValue::Exact("same".to_string()),
            },
            selectors::AppSelector {
                file: PathBuf::from("/repo/web/app/a.tsx"),
                attribute: "data-testid".to_string(),
                value: selectors::AppSelectorValue::Exact("same".to_string()),
            },
            selectors::AppSelector {
                file: PathBuf::from("/repo/web/app/c.tsx"),
                attribute: "data-testid".to_string(),
                value: selectors::AppSelectorValue::Exact("zzz".to_string()),
            },
        ];
        let settings = Settings {
            frontend_root: "web/app".to_string(),
            playwright_configs: vec![],
            project: None,
            test_include: vec![],
            test_exclude: vec![],
            ignore_routes: vec![],
            navigation_helpers: vec![],
            selector_attributes: vec!["data-testid".to_string()],
            component_selector_attributes: BTreeMap::new(),
            selector_roots: vec!["web/app".to_string()],
            selector_include: vec![],
            selector_exclude: vec![],
        };
        let report = build_coverage(
            root,
            &[],
            &app_selectors,
            &app_selectors,
            &[],
            &settings,
            false,
        );
        assert_eq!(report.selectors[0].file, "web/app/a.tsx");
        assert_eq!(report.selectors[1].file, "web/app/b.tsx");
        assert_eq!(report.selectors[2].value, "zzz");
    }

    #[test]
    fn duplicate_selector_report_includes_exact_values_only() {
        let root = Path::new("/repo");
        let app_selectors = vec![
            selectors::AppSelector {
                file: PathBuf::from("/repo/web/app/b.tsx"),
                attribute: "data-pw".to_string(),
                value: selectors::AppSelectorValue::Exact("same".to_string()),
            },
            selectors::AppSelector {
                file: PathBuf::from("/repo/web/app/b.tsx"),
                attribute: "data-pw".to_string(),
                value: selectors::AppSelectorValue::Exact("same".to_string()),
            },
            selectors::AppSelector {
                file: PathBuf::from("/repo/web/app/b.tsx"),
                attribute: "data-testid".to_string(),
                value: selectors::AppSelectorValue::Exact("same".to_string()),
            },
            selectors::AppSelector {
                file: PathBuf::from("/repo/web/app/a.tsx"),
                attribute: "data-testid".to_string(),
                value: selectors::AppSelectorValue::Exact("same".to_string()),
            },
            selectors::AppSelector {
                file: PathBuf::from("/repo/web/app/c.tsx"),
                attribute: "data-testid".to_string(),
                value: selectors::AppSelectorValue::Unsupported("id".to_string()),
            },
            selectors::AppSelector {
                file: PathBuf::from("/repo/web/app/d.tsx"),
                attribute: "data-testid".to_string(),
                value: selectors::AppSelectorValue::Exact("unique".to_string()),
            },
        ];

        let duplicates = build_duplicate_selectors(root, &app_selectors);
        assert_eq!(duplicates.len(), 4);
        assert_eq!(duplicates[0].file, "web/app/a.tsx");
        assert_eq!(duplicates[1].attribute, "data-pw");
        assert_eq!(duplicates[2].attribute, "data-pw");
        assert_eq!(duplicates[3].attribute, "data-testid");
    }

    #[test]
    fn selector_edges_mark_targets_covered() {
        let root = Path::new("/repo");
        let app_selectors = vec![selectors::AppSelector {
            file: PathBuf::from("/repo/web/app/page.tsx"),
            attribute: "data-testid".to_string(),
            value: selectors::AppSelectorValue::Exact("save".to_string()),
        }];
        let edges = vec![Edge::Selector {
            test_file: "tests/e2e/app.spec.ts".to_string(),
            app_file: "web/app/page.tsx".to_string(),
            attribute: "data-testid".to_string(),
            value: "save".to_string(),
            selector: "getByTestId(save)".to_string(),
        }];
        let settings = Settings {
            frontend_root: "web/app".to_string(),
            playwright_configs: vec![],
            project: None,
            test_include: vec![],
            test_exclude: vec![],
            ignore_routes: vec![],
            navigation_helpers: vec![],
            selector_attributes: vec!["data-testid".to_string()],
            component_selector_attributes: BTreeMap::new(),
            selector_roots: vec!["web/app".to_string()],
            selector_include: vec![],
            selector_exclude: vec![],
        };
        let report = build_coverage(
            root,
            &[],
            &app_selectors,
            &app_selectors,
            &edges,
            &settings,
            false,
        );
        assert_eq!(report.summary.covered_selectors, 1);
        assert_eq!(report.selectors[0].tests, vec!["tests/e2e/app.spec.ts"]);
    }

    #[test]
    fn route_edges_mark_routes_covered() {
        let root = Path::new("/repo");
        let routes = vec![Route {
            file: PathBuf::from("/repo/web/app/users/[id]/page.tsx"),
            pattern: "/users/:id".to_string(),
        }];
        let edges = vec![Edge::Route {
            test_file: "tests/e2e/users.spec.ts".to_string(),
            route_file: "web/app/users/[id]/page.tsx".to_string(),
            route: "/users/:id".to_string(),
            url: "/users/42".to_string(),
        }];
        let settings = Settings {
            frontend_root: "web/app".to_string(),
            playwright_configs: vec![],
            project: None,
            test_include: vec![],
            test_exclude: vec![],
            ignore_routes: vec![],
            navigation_helpers: vec![],
            selector_attributes: vec!["data-testid".to_string()],
            component_selector_attributes: BTreeMap::new(),
            selector_roots: vec!["web/app".to_string()],
            selector_include: vec![],
            selector_exclude: vec![],
        };
        let report = build_coverage(root, &routes, &[], &[], &edges, &settings, false);
        assert_eq!(report.summary.covered_routes, 1);
        assert_eq!(report.routes[0].urls, vec!["/users/42"]);
    }

    #[test]
    fn analyze_discovers_tests_and_builds_reports() {
        let root = fixture_path(&["main", "analyze-basic"]);
        let settings = Settings {
            frontend_root: "web/app".to_string(),
            playwright_configs: vec![],
            project: None,
            test_include: vec!["tests/**/*.spec.ts".to_string()],
            test_exclude: vec![],
            ignore_routes: vec![],
            navigation_helpers: vec![],
            selector_attributes: vec![],
            component_selector_attributes: BTreeMap::new(),
            selector_roots: vec!["web/app".to_string()],
            selector_include: vec![],
            selector_exclude: vec![],
        };

        let analysis = analyze(&root, &settings).unwrap();
        assert_eq!(analysis.coverage.summary.covered_routes, 1);
        assert_eq!(analysis.edges.edges.len(), 1);
    }

    #[test]
    fn discover_test_files_walks_shared_project_test_dir_once() {
        let root = fixture_path(&["main", "analyze-basic"]);
        let settings = Settings {
            frontend_root: "web/app".to_string(),
            playwright_configs: vec![],
            project: None,
            test_include: vec![],
            test_exclude: vec![],
            ignore_routes: vec![],
            navigation_helpers: vec![],
            selector_attributes: vec![],
            component_selector_attributes: BTreeMap::new(),
            selector_roots: vec!["web/app".to_string()],
            selector_include: vec![],
            selector_exclude: vec![],
        };
        let playwright = playwright_config::PlaywrightConfig {
            name: None,
            projects: vec![
                playwright_config::TestProject {
                    config_dir: root.clone(),
                    test_dir: "tests".to_string(),
                    test_match: vec!["**/*.spec.ts".to_string()],
                    test_ignore: vec![],
                    base_url: Some("http://localhost:3000".to_string()),
                    test_id_attribute: "data-testid".to_string(),
                },
                playwright_config::TestProject {
                    config_dir: root.clone(),
                    test_dir: "tests".to_string(),
                    test_match: vec!["**/*.spec.ts".to_string()],
                    test_ignore: vec![],
                    base_url: Some("http://localhost:4000".to_string()),
                    test_id_attribute: "data-pw".to_string(),
                },
            ],
        };

        let files = discover_test_files(&root, &settings, &playwright).unwrap();
        assert_eq!(files.len(), 1);
        assert_eq!(files[0].contexts.len(), 2);
    }

    #[test]
    fn discover_test_files_applies_yaml_exclude_before_project_matching() {
        let root = fixture_path(&["main", "analyze-basic"]);
        let settings = Settings {
            frontend_root: "web/app".to_string(),
            playwright_configs: vec![],
            project: None,
            test_include: vec![],
            test_exclude: vec!["tests/**".to_string()],
            ignore_routes: vec![],
            navigation_helpers: vec![],
            selector_attributes: vec![],
            component_selector_attributes: BTreeMap::new(),
            selector_roots: vec!["web/app".to_string()],
            selector_include: vec![],
            selector_exclude: vec![],
        };
        let playwright = playwright_config::PlaywrightConfig {
            name: None,
            projects: vec![playwright_config::TestProject {
                config_dir: root.clone(),
                test_dir: "tests".to_string(),
                test_match: vec!["**/*.spec.ts".to_string()],
                test_ignore: vec![],
                base_url: None,
                test_id_attribute: "data-testid".to_string(),
            }],
        };

        let files = discover_test_files(&root, &settings, &playwright).unwrap();
        assert!(files.is_empty());
    }

    #[test]
    fn analyze_surfaces_parser_errors() {
        let root = fixture_path(&["main", "invalid-test-source"]);
        let settings = Settings {
            frontend_root: "web/app".to_string(),
            playwright_configs: vec![],
            project: None,
            test_include: vec!["tests/**/*.spec.ts".to_string()],
            test_exclude: vec![],
            ignore_routes: vec![],
            navigation_helpers: vec![],
            selector_attributes: vec![],
            component_selector_attributes: BTreeMap::new(),
            selector_roots: vec!["web/app".to_string()],
            selector_include: vec![],
            selector_exclude: vec![],
        };

        let err = analyze(&root, &settings).err().unwrap();
        assert!(err.to_string().contains("failed to parse"));

        let root = fixture_path(&["main", "invalid-selector-source"]);
        let settings = Settings {
            frontend_root: "web/app".to_string(),
            playwright_configs: vec![],
            project: None,
            test_include: vec![],
            test_exclude: vec![],
            ignore_routes: vec![],
            navigation_helpers: vec![],
            selector_attributes: vec!["data-testid".to_string()],
            component_selector_attributes: BTreeMap::new(),
            selector_roots: vec!["web/app".to_string()],
            selector_include: vec![],
            selector_exclude: vec![],
        };
        let selector_regexes = selectors::compile_selector_regexes(
            &settings.selector_attributes,
            &settings.component_selector_attributes,
        );
        let err = collect_app_selectors(&root, &settings, &selector_regexes)
            .err()
            .unwrap();
        assert!(err.to_string().contains("failed to parse"));
    }

    #[test]
    fn text_printers_cover_routes_and_selectors() {
        let coverage = CoverageReport {
            summary: Summary {
                total_routes: 1,
                covered_routes: 0,
                uncovered_routes: 1,
                total_selectors: 1,
                covered_selectors: 0,
                uncovered_selectors: 1,
                duplicate_selectors: 1,
            },
            routes: vec![CoverageRoute {
                route: "/missing".to_string(),
                file: "web/app/missing/page.tsx".to_string(),
                covered: false,
                tests: vec![],
                urls: vec![],
            }],
            selectors: vec![CoverageSelector {
                attribute: "data-testid".to_string(),
                value: "missing".to_string(),
                file: "web/app/page.tsx".to_string(),
                covered: false,
                unsupported_dynamic: false,
                tests: vec![],
                selectors: vec![],
            }],
            duplicate_selectors: vec![DuplicateSelector {
                attribute: "data-testid".to_string(),
                value: "missing".to_string(),
                file: "web/app/other.tsx".to_string(),
            }],
        };
        print_coverage_text(&coverage);

        let edges = EdgeReport {
            edges: vec![
                Edge::Route {
                    test_file: "tests/e2e/app.spec.ts".to_string(),
                    route_file: "web/app/page.tsx".to_string(),
                    route: "/".to_string(),
                    url: "/".to_string(),
                },
                Edge::Selector {
                    test_file: "tests/e2e/app.spec.ts".to_string(),
                    app_file: "web/app/page.tsx".to_string(),
                    attribute: "data-testid".to_string(),
                    value: "save".to_string(),
                    selector: "getByTestId(save)".to_string(),
                },
            ],
        };
        print_edges_text(&edges);
    }

    #[test]
    fn related_report_matches_route_and_selector_edges() {
        let root = Path::new("/repo");
        let edges = vec![
            Edge::Route {
                test_file: "tests/e2e/route.spec.ts".to_string(),
                route_file: "web/app/page.tsx".to_string(),
                route: "/".to_string(),
                url: "/".to_string(),
            },
            Edge::Selector {
                test_file: "tests/e2e/selector.spec.ts".to_string(),
                app_file: "web/app/components/save.tsx".to_string(),
                attribute: "data-testid".to_string(),
                value: "save".to_string(),
                selector: "getByTestId(save)".to_string(),
            },
        ];
        let report = build_related_report(
            root,
            &edges,
            &[
                PathBuf::from("/repo/web/app/page.tsx"),
                PathBuf::from("./web/app/components/save.tsx"),
            ],
        );

        assert_eq!(
            report.tests,
            vec!["tests/e2e/route.spec.ts", "tests/e2e/selector.spec.ts"]
        );
        print_related_text(&report);
    }
}
