use std::collections::{BTreeMap, BTreeSet};
use std::fs;
use std::path::{Path, PathBuf};

use miette::{IntoDiagnostic, Result, bail, miette};
use serde::Deserialize;
use yaml_serde::Value;

pub const DOC_ROOT_DIR: &str = ".docpact";
pub const CONFIG_FILE: &str = ".docpact/config.yaml";
pub const SUPPORTED_REQUIRED_DOC_MODES: &[&str] = &[
    "review_or_update",
    "metadata_refresh_required",
    "body_update_required",
    "must_exist",
];

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ImpactLayout {
    Workspace,
    Repo,
    None,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ConfigScopeKind {
    WorkspaceRoot,
    RepoLocal,
    WorkspaceChildInherited,
}

impl ConfigScopeKind {
    pub fn as_str(self) -> &'static str {
        match self {
            Self::WorkspaceRoot => "workspace-root",
            Self::RepoLocal => "repo-local",
            Self::WorkspaceChildInherited => "workspace-child-inherited",
        }
    }
}

#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
#[serde(rename_all = "lowercase")]
enum ConfigLayout {
    Workspace,
    Repo,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum OverrideMode {
    Merge,
    Replace,
}

impl OverrideMode {
    pub fn as_str(self) -> &'static str {
        match self {
            Self::Merge => "merge",
            Self::Replace => "replace",
        }
    }
}

#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
struct ConfigFile {
    layout: ConfigLayout,
    #[serde(default)]
    coverage: Option<CoverageConfig>,
    #[serde(default)]
    freshness: Option<FreshnessConfig>,
    #[serde(default)]
    routing: Option<RoutingConfig>,
    #[serde(default)]
    catalog: Option<CatalogConfig>,
    #[serde(default)]
    ownership: Option<OwnershipConfig>,
    #[serde(rename = "docInventory", default)]
    doc_inventory: Option<DocInventoryConfig>,
    #[serde(default)]
    rules: Vec<Rule>,
    #[serde(default)]
    workspace: Option<WorkspaceSection>,
    #[serde(default)]
    inherit: Option<InheritConfig>,
    #[serde(default)]
    overrides: Option<OverridesConfig>,
}

#[derive(Debug, Clone, Default, Deserialize, PartialEq, Eq)]
struct WorkspaceSection {
    #[serde(default)]
    pub name: Option<String>,
    #[serde(default)]
    pub profiles: BTreeMap<String, WorkspaceProfile>,
}

#[derive(Debug, Clone, Default, Deserialize, PartialEq, Eq)]
struct WorkspaceProfile {
    #[serde(default)]
    pub coverage: Option<CoverageConfig>,
    #[serde(default)]
    pub freshness: Option<FreshnessConfig>,
    #[serde(default)]
    pub routing: Option<RoutingConfig>,
    #[serde(rename = "docInventory", default)]
    pub doc_inventory: Option<DocInventoryConfig>,
    #[serde(default)]
    pub rules: Vec<Rule>,
}

#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
struct InheritConfig {
    pub workspace_profile: String,
}

#[derive(Debug, Clone, Default, Deserialize, PartialEq, Eq)]
struct OverridesConfig {
    #[serde(default)]
    pub rules: RuleOverrides,
    #[serde(default)]
    pub coverage: Option<ScopedPatternOverride>,
    #[serde(rename = "docInventory", default)]
    pub doc_inventory: Option<ScopedPatternOverride>,
    #[serde(default)]
    pub freshness: Option<FreshnessOverride>,
    #[serde(default)]
    pub routing: Option<RoutingOverride>,
}

#[derive(Debug, Clone, Default, Deserialize, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
pub struct CatalogConfig {
    #[serde(default)]
    pub repos: Vec<CatalogRepo>,
}

#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
pub struct CatalogRepo {
    pub id: String,
    pub path: String,
    #[serde(rename = "canonicalRepo", default)]
    pub canonical_repo: Option<String>,
    #[serde(rename = "entryDoc", default)]
    pub entry_doc: Option<String>,
    #[serde(rename = "branchPolicyDoc", default)]
    pub branch_policy_doc: Option<String>,
    #[serde(rename = "workflowDocs", default)]
    pub workflow_docs: Vec<String>,
    #[serde(rename = "integrationDocs", default)]
    pub integration_docs: Vec<String>,
    #[serde(rename = "workspaceIntegrationRequired", default)]
    pub workspace_integration_required: bool,
}

#[derive(Debug, Clone, Default, Deserialize, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
pub struct OwnershipConfig {
    #[serde(default)]
    pub domains: Vec<OwnershipDomain>,
}

#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
pub struct OwnershipDomain {
    pub id: String,
    pub paths: OwnershipPaths,
    #[serde(rename = "ownerRepo")]
    pub owner_repo: String,
    #[serde(rename = "nonOwnerRepos", default)]
    pub non_owner_repos: Vec<String>,
}

#[derive(Debug, Clone, Default, Deserialize, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
pub struct OwnershipPaths {
    #[serde(default)]
    pub include: Vec<String>,
    #[serde(default)]
    pub exclude: Vec<String>,
}

#[derive(Debug, Clone, Default, Deserialize, PartialEq, Eq)]
struct RuleOverrides {
    #[serde(default)]
    pub add: Vec<Rule>,
    #[serde(default)]
    pub replace: Vec<Rule>,
    #[serde(default)]
    pub disable: Vec<RuleDisable>,
}

#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
struct RuleDisable {
    pub id: String,
    pub reason: String,
}

#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
struct ScopedPatternOverride {
    pub mode: OverrideMode,
    #[serde(default)]
    pub include: Vec<String>,
    #[serde(default)]
    pub exclude: Vec<String>,
}

#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
struct FreshnessOverride {
    pub mode: OverrideMode,
    #[serde(default = "default_warn_after_commits")]
    pub warn_after_commits: usize,
    #[serde(default = "default_warn_after_days")]
    pub warn_after_days: usize,
    #[serde(default = "default_critical_after_days")]
    pub critical_after_days: usize,
}

#[derive(Debug, Clone, Default, Deserialize, PartialEq, Eq)]
pub struct RoutingConfig {
    #[serde(default)]
    pub intents: BTreeMap<String, RoutingIntent>,
}

#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
pub struct RoutingIntent {
    #[serde(default)]
    pub paths: Vec<String>,
}

#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
struct RoutingOverride {
    pub mode: OverrideMode,
    #[serde(default)]
    pub intents: BTreeMap<String, RoutingIntent>,
}

#[derive(Debug, Clone, Default, Deserialize, PartialEq, Eq)]
pub struct CoverageConfig {
    #[serde(default)]
    pub include: Vec<String>,
    #[serde(default)]
    pub exclude: Vec<String>,
}

#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
pub struct FreshnessConfig {
    #[serde(default = "default_warn_after_commits")]
    pub warn_after_commits: usize,
    #[serde(default = "default_warn_after_days")]
    pub warn_after_days: usize,
    #[serde(default = "default_critical_after_days")]
    pub critical_after_days: usize,
}

impl Default for FreshnessConfig {
    fn default() -> Self {
        Self {
            warn_after_commits: default_warn_after_commits(),
            warn_after_days: default_warn_after_days(),
            critical_after_days: default_critical_after_days(),
        }
    }
}

#[derive(Debug, Clone, Default, Deserialize, PartialEq, Eq)]
pub struct DocInventoryConfig {
    #[serde(default)]
    pub include: Vec<String>,
    #[serde(default)]
    pub exclude: Vec<String>,
}

#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
pub struct Rule {
    pub id: String,
    pub scope: String,
    pub repo: String,
    #[serde(default)]
    pub triggers: Vec<Trigger>,
    #[serde(rename = "requiredDocs", default)]
    pub required_docs: Vec<RequiredDoc>,
    pub reason: String,
}

#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
pub struct Trigger {
    pub path: String,
    pub kind: Option<String>,
}

#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
pub struct RequiredDoc {
    pub path: String,
    pub mode: Option<String>,
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ImpactFileDescriptor {
    pub abs_path: PathBuf,
    pub rel_path: String,
    pub base_dir: String,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RuleOriginKind {
    RootLocal,
    RepoLocal,
    WorkspaceProfile,
    OverrideAdd,
    OverrideReplace,
}

impl RuleOriginKind {
    pub fn as_str(self) -> &'static str {
        match self {
            Self::RootLocal => "root-local",
            Self::RepoLocal => "repo-local",
            Self::WorkspaceProfile => "workspace-profile",
            Self::OverrideAdd => "override-add",
            Self::OverrideReplace => "override-replace",
        }
    }
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RuleProvenance {
    pub config_source: String,
    pub origin_kind: RuleOriginKind,
    pub workspace_profile: Option<String>,
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct LoadedRule {
    pub source: String,
    pub config_source: String,
    pub base_dir: String,
    pub rule: Rule,
    pub provenance: RuleProvenance,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ConfigBlockSourceKind {
    Local,
    WorkspaceProfile,
    OverrideMerge,
    OverrideReplace,
    Default,
}

impl ConfigBlockSourceKind {
    pub fn as_str(self) -> &'static str {
        match self {
            Self::Local => "local",
            Self::WorkspaceProfile => "workspace-profile",
            Self::OverrideMerge => "override-merge",
            Self::OverrideReplace => "override-replace",
            Self::Default => "default",
        }
    }
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct BlockResolution {
    pub origin_kind: ConfigBlockSourceKind,
    pub workspace_profile: Option<String>,
    pub mode: Option<String>,
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct LoadedCoverageConfig {
    pub source: String,
    pub base_dir: String,
    pub coverage: CoverageConfig,
    pub resolution: BlockResolution,
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct LoadedFreshnessConfig {
    pub source: String,
    pub base_dir: String,
    pub freshness: FreshnessConfig,
    pub resolution: BlockResolution,
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct LoadedDocInventoryConfig {
    pub source: String,
    pub base_dir: String,
    pub doc_inventory: DocInventoryConfig,
    pub resolution: BlockResolution,
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct LoadedRoutingConfig {
    pub source: String,
    pub base_dir: String,
    pub scope_kind: ConfigScopeKind,
    pub repo_id: Option<String>,
    pub routing: RoutingConfig,
    pub resolution: BlockResolution,
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct LoadedCatalogConfig {
    pub source: String,
    pub base_dir: String,
    pub scope_kind: ConfigScopeKind,
    pub repo_id: Option<String>,
    pub catalog: CatalogConfig,
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct LoadedOwnershipConfig {
    pub source: String,
    pub base_dir: String,
    pub scope_kind: ConfigScopeKind,
    pub repo_id: Option<String>,
    pub ownership: OwnershipConfig,
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct OwnershipDomainMatch {
    pub source: String,
    pub base_dir: String,
    pub domain_id: String,
    pub owner_repo: String,
    pub non_owner_repos: Vec<String>,
    pub matched_include: String,
    pub static_prefix_len: usize,
    pub wildcard_count: usize,
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct OwnershipPathAnalysis {
    pub path: String,
    pub matches: Vec<OwnershipDomainMatch>,
    pub selected: OwnershipDomainMatch,
    pub has_conflict: bool,
    pub has_overlap: bool,
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct OwnershipConflict {
    pub path: String,
    pub owner_repos: Vec<String>,
    pub domain_ids: Vec<String>,
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct OwnershipOverlap {
    pub path: String,
    pub owner_repo: String,
    pub domain_ids: Vec<String>,
    pub selected_domain_id: String,
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct OwnershipAnalysis {
    pub paths: Vec<OwnershipPathAnalysis>,
    pub conflicts: Vec<OwnershipConflict>,
    pub overlaps: Vec<OwnershipOverlap>,
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct InheritanceResolution {
    pub workspace_profile: String,
    pub add_count: usize,
    pub replace_count: usize,
    pub disable_count: usize,
    pub disabled_rule_ids: Vec<String>,
    pub coverage_mode: Option<String>,
    pub doc_inventory_mode: Option<String>,
    pub freshness_mode: Option<String>,
    pub routing_mode: Option<String>,
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct EffectiveConfig {
    pub source: String,
    pub base_dir: String,
    pub rules: Vec<LoadedRule>,
    pub coverage: LoadedCoverageConfig,
    pub freshness: LoadedFreshnessConfig,
    pub routing: LoadedRoutingConfig,
    pub doc_inventory: LoadedDocInventoryConfig,
    pub inheritance: Option<InheritanceResolution>,
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ConfigValidationProblem {
    pub source: String,
    pub rule_id: Option<String>,
    pub message: String,
}

#[derive(Debug, Clone)]
struct ParsedImpactFile {
    descriptor: ImpactFileDescriptor,
    parsed: ConfigFile,
}

pub fn normalize_path(value: &str) -> String {
    let replaced = value.replace('\\', "/");
    let trimmed = replaced.trim_start_matches("./");
    let mut output = String::with_capacity(trimmed.len());
    let mut previous_was_slash = false;

    for ch in trimmed.chars() {
        if ch == '/' {
            if !previous_was_slash {
                output.push(ch);
            }
            previous_was_slash = true;
        } else {
            previous_was_slash = false;
            output.push(ch);
        }
    }

    output
}

fn default_warn_after_commits() -> usize {
    50
}

fn default_warn_after_days() -> usize {
    90
}

fn default_critical_after_days() -> usize {
    180
}

pub fn path_relative_to(root_dir: &Path, path: &Path) -> String {
    match path.strip_prefix(root_dir) {
        Ok(relative) => normalize_path(&relative.to_string_lossy()),
        Err(_) => normalize_path(&path.to_string_lossy()),
    }
}

fn layout_from_config(layout: ConfigLayout) -> ImpactLayout {
    match layout {
        ConfigLayout::Workspace => ImpactLayout::Workspace,
        ConfigLayout::Repo => ImpactLayout::Repo,
    }
}

fn scope_kind_for_parsed(parsed: &ParsedImpactFile) -> ConfigScopeKind {
    if parsed.descriptor.base_dir.is_empty() && parsed.parsed.layout == ConfigLayout::Workspace {
        ConfigScopeKind::WorkspaceRoot
    } else if parsed.parsed.inherit.is_some() {
        ConfigScopeKind::WorkspaceChildInherited
    } else {
        ConfigScopeKind::RepoLocal
    }
}

fn infer_repo_id(
    parsed: &ParsedImpactFile,
    workspace_root: Option<&ParsedImpactFile>,
) -> Option<String> {
    if let Some(repo_id) = parsed
        .parsed
        .catalog
        .as_ref()
        .and_then(|catalog| infer_local_catalog_repo_id(&parsed.descriptor.base_dir, catalog))
    {
        return Some(repo_id);
    }

    let root = workspace_root?;
    if parsed.descriptor.base_dir.is_empty() {
        return None;
    }

    root.parsed.catalog.as_ref().and_then(|catalog| {
        catalog
            .repos
            .iter()
            .find(|repo| {
                !repo.id.trim().is_empty()
                    && normalized_catalog_repo_root(&root.descriptor.base_dir, &repo.path)
                        == parsed.descriptor.base_dir
            })
            .map(|repo| repo.id.trim().to_string())
    })
}

fn infer_local_catalog_repo_id(base_dir: &str, catalog: &CatalogConfig) -> Option<String> {
    catalog
        .repos
        .iter()
        .find(|repo| {
            !repo.id.trim().is_empty()
                && normalized_catalog_repo_root(base_dir, &repo.path) == normalize_path(base_dir)
        })
        .map(|repo| repo.id.trim().to_string())
        .or_else(|| {
            if catalog.repos.len() == 1 && !catalog.repos[0].id.trim().is_empty() {
                Some(catalog.repos[0].id.trim().to_string())
            } else {
                None
            }
        })
}

fn normalized_catalog_repo_root(base_dir: &str, repo_path: &str) -> String {
    if repo_path == "." {
        normalize_path(base_dir)
    } else {
        resolve_rule_path(base_dir, repo_path)
    }
}

pub fn detect_impact_layout(
    root_dir: &Path,
    config_override: Option<&Path>,
) -> Result<ImpactLayout> {
    let config_path = match config_override {
        Some(path) => path.to_path_buf(),
        None => root_dir.join(CONFIG_FILE),
    };

    if !config_path.exists() {
        return Ok(ImpactLayout::None);
    }

    let rel_path = path_relative_to(root_dir, &config_path);
    let parsed = load_config_file(&config_path, &rel_path)?;
    Ok(layout_from_config(parsed.layout))
}

pub fn parse_yaml_value(text: &str, source_label: &str) -> Result<Value> {
    yaml_serde::from_str::<Value>(text)
        .map_err(|error| miette!("{source_label} is not valid YAML for docpact. {}", error))
}

pub fn load_yaml_value(abs_path: &Path, source_label: &str) -> Result<Value> {
    let text = fs::read_to_string(abs_path).into_diagnostic()?;
    parse_yaml_value(&text, source_label)
}

fn load_config_file(abs_path: &Path, source_label: &str) -> Result<ConfigFile> {
    let text = fs::read_to_string(abs_path).into_diagnostic()?;
    yaml_serde::from_str::<ConfigFile>(&text)
        .map_err(|error| miette!("{source_label} is not a valid docpact config file. {error}"))
}

fn descriptor(abs_path: PathBuf, rel_path: String, base_dir: String) -> ImpactFileDescriptor {
    ImpactFileDescriptor {
        abs_path,
        rel_path,
        base_dir,
    }
}

pub fn list_impact_files(
    root_dir: &Path,
    config_override: Option<&Path>,
) -> Result<Vec<ImpactFileDescriptor>> {
    let root_config_path = match config_override {
        Some(path) => path.to_path_buf(),
        None => root_dir.join(CONFIG_FILE),
    };

    if !root_config_path.exists() {
        return Ok(Vec::new());
    }

    let rel_path = path_relative_to(root_dir, &root_config_path);
    let parsed = load_config_file(&root_config_path, &rel_path)?;
    let mut results = vec![descriptor(root_config_path, rel_path, String::new())];

    if parsed.layout == ConfigLayout::Workspace {
        results.extend(list_workspace_repo_files(root_dir)?);
    }

    Ok(results)
}

fn list_workspace_repo_files(root_dir: &Path) -> Result<Vec<ImpactFileDescriptor>> {
    let mut repo_dirs = fs::read_dir(root_dir)
        .into_diagnostic()?
        .collect::<std::result::Result<Vec<_>, _>>()
        .into_diagnostic()?;

    repo_dirs.sort_by_key(|entry| entry.file_name());

    let mut results = Vec::new();

    for entry in repo_dirs {
        let file_type = entry.file_type().into_diagnostic()?;
        if !file_type.is_dir() {
            continue;
        }

        let name = entry.file_name();
        let name = name.to_string_lossy();
        if name.starts_with('.') {
            continue;
        }

        let repo_config = entry.path().join(CONFIG_FILE);
        if !repo_config.exists() {
            continue;
        }

        results.push(descriptor(
            repo_config,
            normalize_path(&format!("{name}/{CONFIG_FILE}")),
            name.into_owned(),
        ));
    }

    Ok(results)
}

fn load_parsed_impact_files(
    root_dir: &Path,
    config_override: Option<&Path>,
) -> Result<Vec<ParsedImpactFile>> {
    let mut parsed_files = Vec::new();

    for descriptor in list_impact_files(root_dir, config_override)? {
        parsed_files.push(ParsedImpactFile {
            parsed: load_config_file(&descriptor.abs_path, &descriptor.rel_path)?,
            descriptor,
        });
    }

    Ok(parsed_files)
}

pub fn load_effective_configs(
    root_dir: &Path,
    config_override: Option<&Path>,
) -> Result<Vec<EffectiveConfig>> {
    let parsed_files = load_parsed_impact_files(root_dir, config_override)?;
    if parsed_files.is_empty() {
        return Ok(Vec::new());
    }

    let root = &parsed_files[0];
    match root.parsed.layout {
        ConfigLayout::Repo => Ok(vec![resolve_repo_local_effective_config(root)?]),
        ConfigLayout::Workspace => {
            let mut effective = vec![resolve_workspace_root_effective_config(root)?];
            for child in parsed_files.iter().skip(1) {
                effective.push(resolve_workspace_child_effective_config(root, child)?);
            }
            Ok(effective)
        }
    }
}

pub fn load_impact_files(
    root_dir: &Path,
    config_override: Option<&Path>,
) -> Result<Vec<LoadedRule>> {
    let mut loaded = Vec::new();

    for effective in load_effective_configs(root_dir, config_override)? {
        loaded.extend(effective.rules);
    }

    Ok(loaded)
}

pub fn load_coverage_configs(
    root_dir: &Path,
    config_override: Option<&Path>,
) -> Result<Vec<LoadedCoverageConfig>> {
    Ok(load_effective_configs(root_dir, config_override)?
        .into_iter()
        .map(|effective| effective.coverage)
        .collect())
}

pub fn load_freshness_configs(
    root_dir: &Path,
    config_override: Option<&Path>,
) -> Result<Vec<LoadedFreshnessConfig>> {
    Ok(load_effective_configs(root_dir, config_override)?
        .into_iter()
        .map(|effective| effective.freshness)
        .collect())
}

pub fn load_routing_configs(
    root_dir: &Path,
    config_override: Option<&Path>,
) -> Result<Vec<LoadedRoutingConfig>> {
    Ok(load_effective_configs(root_dir, config_override)?
        .into_iter()
        .map(|effective| effective.routing)
        .collect())
}

pub fn load_catalog_configs(
    root_dir: &Path,
    config_override: Option<&Path>,
) -> Result<Vec<LoadedCatalogConfig>> {
    let parsed_files = load_parsed_impact_files(root_dir, config_override)?;
    let workspace_root = parsed_files
        .first()
        .filter(|parsed| parsed.parsed.layout == ConfigLayout::Workspace)
        .cloned();

    Ok(parsed_files
        .into_iter()
        .map(|parsed| LoadedCatalogConfig {
            source: parsed.descriptor.rel_path.clone(),
            base_dir: parsed.descriptor.base_dir.clone(),
            scope_kind: scope_kind_for_parsed(&parsed),
            repo_id: infer_repo_id(&parsed, workspace_root.as_ref()),
            catalog: normalize_catalog_config(&parsed.parsed.catalog.unwrap_or_default()),
        })
        .collect())
}

pub fn load_ownership_configs(
    root_dir: &Path,
    config_override: Option<&Path>,
) -> Result<Vec<LoadedOwnershipConfig>> {
    let parsed_files = load_parsed_impact_files(root_dir, config_override)?;
    let workspace_root = parsed_files
        .first()
        .filter(|parsed| parsed.parsed.layout == ConfigLayout::Workspace)
        .cloned();

    Ok(parsed_files
        .into_iter()
        .map(|parsed| LoadedOwnershipConfig {
            source: parsed.descriptor.rel_path.clone(),
            base_dir: parsed.descriptor.base_dir.clone(),
            scope_kind: scope_kind_for_parsed(&parsed),
            repo_id: infer_repo_id(&parsed, workspace_root.as_ref()),
            ownership: normalize_ownership_config(&parsed.parsed.ownership.unwrap_or_default()),
        })
        .collect())
}

pub fn load_doc_inventory_configs(
    root_dir: &Path,
    config_override: Option<&Path>,
) -> Result<Vec<LoadedDocInventoryConfig>> {
    Ok(load_effective_configs(root_dir, config_override)?
        .into_iter()
        .map(|effective| effective.doc_inventory)
        .collect())
}

pub fn validate_config_graph(
    root_dir: &Path,
    config_override: Option<&Path>,
) -> Result<Vec<ConfigValidationProblem>> {
    let parsed_files = load_parsed_impact_files(root_dir, config_override)?;
    Ok(validate_parsed_configs(&parsed_files))
}

fn resolve_repo_local_effective_config(parsed: &ParsedImpactFile) -> Result<EffectiveConfig> {
    if parsed.parsed.inherit.is_some() {
        bail!(
            "{} declares `inherit`, but workspace inheritance is only available when a workspace root config is the active entrypoint",
            parsed.descriptor.rel_path
        );
    }
    if parsed.parsed.overrides.is_some() {
        bail!(
            "{} declares `overrides` without `inherit.workspace_profile`",
            parsed.descriptor.rel_path
        );
    }

    let scope_kind = scope_kind_for_parsed(parsed);
    let repo_id = infer_repo_id(parsed, None);

    Ok(EffectiveConfig {
        source: parsed.descriptor.rel_path.clone(),
        base_dir: parsed.descriptor.base_dir.clone(),
        rules: parsed
            .parsed
            .rules
            .iter()
            .cloned()
            .map(|rule| LoadedRule {
                source: parsed.descriptor.rel_path.clone(),
                config_source: parsed.descriptor.rel_path.clone(),
                base_dir: parsed.descriptor.base_dir.clone(),
                provenance: RuleProvenance {
                    config_source: parsed.descriptor.rel_path.clone(),
                    origin_kind: if parsed.descriptor.base_dir.is_empty() {
                        RuleOriginKind::RootLocal
                    } else {
                        RuleOriginKind::RepoLocal
                    },
                    workspace_profile: None,
                },
                rule,
            })
            .collect(),
        coverage: LoadedCoverageConfig {
            source: parsed.descriptor.rel_path.clone(),
            base_dir: parsed.descriptor.base_dir.clone(),
            coverage: parsed.parsed.coverage.clone().unwrap_or_default(),
            resolution: BlockResolution {
                origin_kind: if parsed.parsed.coverage.is_some() {
                    ConfigBlockSourceKind::Local
                } else {
                    ConfigBlockSourceKind::Default
                },
                workspace_profile: None,
                mode: None,
            },
        },
        freshness: LoadedFreshnessConfig {
            source: parsed.descriptor.rel_path.clone(),
            base_dir: parsed.descriptor.base_dir.clone(),
            freshness: parsed.parsed.freshness.clone().unwrap_or_default(),
            resolution: BlockResolution {
                origin_kind: if parsed.parsed.freshness.is_some() {
                    ConfigBlockSourceKind::Local
                } else {
                    ConfigBlockSourceKind::Default
                },
                workspace_profile: None,
                mode: None,
            },
        },
        routing: LoadedRoutingConfig {
            source: parsed.descriptor.rel_path.clone(),
            base_dir: parsed.descriptor.base_dir.clone(),
            scope_kind,
            repo_id,
            routing: parsed.parsed.routing.clone().unwrap_or_default(),
            resolution: BlockResolution {
                origin_kind: if parsed.parsed.routing.is_some() {
                    ConfigBlockSourceKind::Local
                } else {
                    ConfigBlockSourceKind::Default
                },
                workspace_profile: None,
                mode: None,
            },
        },
        doc_inventory: LoadedDocInventoryConfig {
            source: parsed.descriptor.rel_path.clone(),
            base_dir: parsed.descriptor.base_dir.clone(),
            doc_inventory: parsed.parsed.doc_inventory.clone().unwrap_or_default(),
            resolution: BlockResolution {
                origin_kind: if parsed.parsed.doc_inventory.is_some() {
                    ConfigBlockSourceKind::Local
                } else {
                    ConfigBlockSourceKind::Default
                },
                workspace_profile: None,
                mode: None,
            },
        },
        inheritance: None,
    })
}

fn resolve_workspace_root_effective_config(root: &ParsedImpactFile) -> Result<EffectiveConfig> {
    if root.parsed.inherit.is_some() {
        bail!(
            "{} must not declare `inherit` because workspace roots cannot inherit from another config",
            root.descriptor.rel_path
        );
    }
    if root.parsed.overrides.is_some() {
        bail!(
            "{} must not declare `overrides` because workspace roots define local config and reusable profiles directly",
            root.descriptor.rel_path
        );
    }

    let scope_kind = scope_kind_for_parsed(root);
    let repo_id = infer_repo_id(root, None);

    Ok(EffectiveConfig {
        source: root.descriptor.rel_path.clone(),
        base_dir: root.descriptor.base_dir.clone(),
        rules: root
            .parsed
            .rules
            .iter()
            .cloned()
            .map(|rule| LoadedRule {
                source: root.descriptor.rel_path.clone(),
                config_source: root.descriptor.rel_path.clone(),
                base_dir: root.descriptor.base_dir.clone(),
                provenance: RuleProvenance {
                    config_source: root.descriptor.rel_path.clone(),
                    origin_kind: RuleOriginKind::RootLocal,
                    workspace_profile: None,
                },
                rule,
            })
            .collect(),
        coverage: LoadedCoverageConfig {
            source: root.descriptor.rel_path.clone(),
            base_dir: root.descriptor.base_dir.clone(),
            coverage: root.parsed.coverage.clone().unwrap_or_default(),
            resolution: BlockResolution {
                origin_kind: if root.parsed.coverage.is_some() {
                    ConfigBlockSourceKind::Local
                } else {
                    ConfigBlockSourceKind::Default
                },
                workspace_profile: None,
                mode: None,
            },
        },
        freshness: LoadedFreshnessConfig {
            source: root.descriptor.rel_path.clone(),
            base_dir: root.descriptor.base_dir.clone(),
            freshness: root.parsed.freshness.clone().unwrap_or_default(),
            resolution: BlockResolution {
                origin_kind: if root.parsed.freshness.is_some() {
                    ConfigBlockSourceKind::Local
                } else {
                    ConfigBlockSourceKind::Default
                },
                workspace_profile: None,
                mode: None,
            },
        },
        routing: LoadedRoutingConfig {
            source: root.descriptor.rel_path.clone(),
            base_dir: root.descriptor.base_dir.clone(),
            scope_kind,
            repo_id,
            routing: root.parsed.routing.clone().unwrap_or_default(),
            resolution: BlockResolution {
                origin_kind: if root.parsed.routing.is_some() {
                    ConfigBlockSourceKind::Local
                } else {
                    ConfigBlockSourceKind::Default
                },
                workspace_profile: None,
                mode: None,
            },
        },
        doc_inventory: LoadedDocInventoryConfig {
            source: root.descriptor.rel_path.clone(),
            base_dir: root.descriptor.base_dir.clone(),
            doc_inventory: root.parsed.doc_inventory.clone().unwrap_or_default(),
            resolution: BlockResolution {
                origin_kind: if root.parsed.doc_inventory.is_some() {
                    ConfigBlockSourceKind::Local
                } else {
                    ConfigBlockSourceKind::Default
                },
                workspace_profile: None,
                mode: None,
            },
        },
        inheritance: None,
    })
}

fn resolve_workspace_child_effective_config(
    root: &ParsedImpactFile,
    child: &ParsedImpactFile,
) -> Result<EffectiveConfig> {
    if child.parsed.layout != ConfigLayout::Repo {
        bail!(
            "{} must use `layout: repo` when loaded from a workspace root",
            child.descriptor.rel_path
        );
    }

    let Some(inherit) = child.parsed.inherit.as_ref() else {
        if child.parsed.overrides.is_some() {
            bail!(
                "{} declares `overrides` without `inherit.workspace_profile`",
                child.descriptor.rel_path
            );
        }
        return resolve_repo_local_effective_config(child);
    };

    if !child.parsed.rules.is_empty()
        || child.parsed.coverage.is_some()
        || child.parsed.freshness.is_some()
        || child.parsed.routing.is_some()
        || child.parsed.doc_inventory.is_some()
    {
        bail!(
            "{} uses workspace inheritance and therefore must place all runtime changes under `overrides` instead of top-level `rules`, `coverage`, `freshness`, `routing`, or `docInventory`",
            child.descriptor.rel_path
        );
    }

    let workspace = root.parsed.workspace.as_ref().ok_or_else(|| {
        miette!(
            "{} is a workspace config but does not define a `workspace` block",
            root.descriptor.rel_path
        )
    })?;
    let profile = workspace
        .profiles
        .get(&inherit.workspace_profile)
        .ok_or_else(|| {
            miette!(
                "{} references missing workspace profile `{}` from {}",
                child.descriptor.rel_path,
                inherit.workspace_profile,
                root.descriptor.rel_path
            )
        })?;

    let overrides = child.parsed.overrides.clone().unwrap_or_default();
    let resolved_rules = resolve_inherited_rules(
        root,
        child,
        &inherit.workspace_profile,
        profile,
        &overrides.rules,
    )?;
    let (coverage, coverage_resolution) = resolve_scoped_patterns(
        profile.coverage.as_ref(),
        overrides.coverage.as_ref(),
        &inherit.workspace_profile,
    );
    let (routing, routing_resolution) = resolve_routing(
        profile.routing.as_ref(),
        overrides.routing.as_ref(),
        &inherit.workspace_profile,
    );
    let (doc_inventory, doc_inventory_resolution) = resolve_doc_inventory(
        profile.doc_inventory.as_ref(),
        overrides.doc_inventory.as_ref(),
        &inherit.workspace_profile,
    );
    let (freshness, freshness_resolution) = resolve_freshness(
        profile.freshness.as_ref(),
        overrides.freshness.as_ref(),
        &inherit.workspace_profile,
    )?;
    let scope_kind = scope_kind_for_parsed(child);
    let repo_id = infer_repo_id(child, Some(root));

    Ok(EffectiveConfig {
        source: child.descriptor.rel_path.clone(),
        base_dir: child.descriptor.base_dir.clone(),
        rules: resolved_rules,
        coverage: LoadedCoverageConfig {
            source: child.descriptor.rel_path.clone(),
            base_dir: child.descriptor.base_dir.clone(),
            coverage,
            resolution: coverage_resolution,
        },
        freshness: LoadedFreshnessConfig {
            source: child.descriptor.rel_path.clone(),
            base_dir: child.descriptor.base_dir.clone(),
            freshness,
            resolution: freshness_resolution,
        },
        routing: LoadedRoutingConfig {
            source: child.descriptor.rel_path.clone(),
            base_dir: child.descriptor.base_dir.clone(),
            scope_kind,
            repo_id,
            routing,
            resolution: routing_resolution,
        },
        doc_inventory: LoadedDocInventoryConfig {
            source: child.descriptor.rel_path.clone(),
            base_dir: child.descriptor.base_dir.clone(),
            doc_inventory,
            resolution: doc_inventory_resolution,
        },
        inheritance: Some(InheritanceResolution {
            workspace_profile: inherit.workspace_profile.clone(),
            add_count: overrides.rules.add.len(),
            replace_count: overrides.rules.replace.len(),
            disable_count: overrides.rules.disable.len(),
            disabled_rule_ids: overrides
                .rules
                .disable
                .iter()
                .map(|rule| rule.id.clone())
                .collect(),
            coverage_mode: overrides
                .coverage
                .as_ref()
                .map(|override_block| override_block.mode.as_str().to_string()),
            doc_inventory_mode: overrides
                .doc_inventory
                .as_ref()
                .map(|override_block| override_block.mode.as_str().to_string()),
            freshness_mode: overrides
                .freshness
                .as_ref()
                .map(|override_block| override_block.mode.as_str().to_string()),
            routing_mode: overrides
                .routing
                .as_ref()
                .map(|override_block| override_block.mode.as_str().to_string()),
        }),
    })
}

fn resolve_inherited_rules(
    root: &ParsedImpactFile,
    child: &ParsedImpactFile,
    profile_name: &str,
    profile: &WorkspaceProfile,
    overrides: &RuleOverrides,
) -> Result<Vec<LoadedRule>> {
    let mut ordered = Vec::<(String, LoadedRule)>::new();

    for rule in &profile.rules {
        let source =
            workspace_profile_rule_source(&root.descriptor.rel_path, profile_name, &rule.id);
        ordered.push((
            rule.id.clone(),
            LoadedRule {
                source,
                config_source: child.descriptor.rel_path.clone(),
                base_dir: child.descriptor.base_dir.clone(),
                provenance: RuleProvenance {
                    config_source: root.descriptor.rel_path.clone(),
                    origin_kind: RuleOriginKind::WorkspaceProfile,
                    workspace_profile: Some(profile_name.to_string()),
                },
                rule: rule.clone(),
            },
        ));
    }

    for rule in &overrides.replace {
        let source = override_rule_source(&child.descriptor.rel_path, "replace", &rule.id);
        let Some(position) = ordered.iter().position(|(id, _)| id == &rule.id) else {
            bail!(
                "{} tries to replace inherited rule `{}` but that rule does not exist in workspace profile `{}`",
                child.descriptor.rel_path,
                rule.id,
                profile_name
            );
        };
        ordered[position] = (
            rule.id.clone(),
            LoadedRule {
                source,
                config_source: child.descriptor.rel_path.clone(),
                base_dir: child.descriptor.base_dir.clone(),
                provenance: RuleProvenance {
                    config_source: child.descriptor.rel_path.clone(),
                    origin_kind: RuleOriginKind::OverrideReplace,
                    workspace_profile: Some(profile_name.to_string()),
                },
                rule: rule.clone(),
            },
        );
    }

    let profile_rule_ids = profile
        .rules
        .iter()
        .map(|rule| rule.id.as_str())
        .collect::<BTreeSet<_>>();

    for disabled in &overrides.disable {
        if !profile_rule_ids.contains(disabled.id.as_str()) {
            bail!(
                "{} tries to disable `{}` but that rule is not inherited from workspace profile `{}`",
                child.descriptor.rel_path,
                disabled.id,
                profile_name
            );
        }
        let Some(position) = ordered.iter().position(|(id, _)| id == &disabled.id) else {
            bail!(
                "{} tries to disable `{}` but that rule is not active after replacements",
                child.descriptor.rel_path,
                disabled.id
            );
        };
        ordered.remove(position);
    }

    for rule in &overrides.add {
        if ordered.iter().any(|(id, _)| id == &rule.id) {
            bail!(
                "{} tries to add rule `{}` but that id is already active in inherited config",
                child.descriptor.rel_path,
                rule.id
            );
        }
        let source = override_rule_source(&child.descriptor.rel_path, "add", &rule.id);
        ordered.push((
            rule.id.clone(),
            LoadedRule {
                source,
                config_source: child.descriptor.rel_path.clone(),
                base_dir: child.descriptor.base_dir.clone(),
                provenance: RuleProvenance {
                    config_source: child.descriptor.rel_path.clone(),
                    origin_kind: RuleOriginKind::OverrideAdd,
                    workspace_profile: Some(profile_name.to_string()),
                },
                rule: rule.clone(),
            },
        ));
    }

    Ok(ordered.into_iter().map(|(_, loaded)| loaded).collect())
}

fn resolve_scoped_patterns(
    inherited: Option<&CoverageConfig>,
    override_block: Option<&ScopedPatternOverride>,
    profile_name: &str,
) -> (CoverageConfig, BlockResolution) {
    match override_block {
        Some(override_block) if override_block.mode == OverrideMode::Replace => (
            CoverageConfig {
                include: sorted_unique_patterns(&override_block.include),
                exclude: sorted_unique_patterns(&override_block.exclude),
            },
            BlockResolution {
                origin_kind: ConfigBlockSourceKind::OverrideReplace,
                workspace_profile: Some(profile_name.to_string()),
                mode: Some(override_block.mode.as_str().to_string()),
            },
        ),
        Some(override_block) => {
            let base = inherited.cloned().unwrap_or_default();
            (
                CoverageConfig {
                    include: merge_patterns(&base.include, &override_block.include),
                    exclude: merge_patterns(&base.exclude, &override_block.exclude),
                },
                BlockResolution {
                    origin_kind: ConfigBlockSourceKind::OverrideMerge,
                    workspace_profile: Some(profile_name.to_string()),
                    mode: Some(override_block.mode.as_str().to_string()),
                },
            )
        }
        None => match inherited {
            Some(inherited) => (
                inherited.clone(),
                BlockResolution {
                    origin_kind: ConfigBlockSourceKind::WorkspaceProfile,
                    workspace_profile: Some(profile_name.to_string()),
                    mode: None,
                },
            ),
            None => (
                CoverageConfig::default(),
                BlockResolution {
                    origin_kind: ConfigBlockSourceKind::Default,
                    workspace_profile: Some(profile_name.to_string()),
                    mode: None,
                },
            ),
        },
    }
}

fn resolve_doc_inventory(
    inherited: Option<&DocInventoryConfig>,
    override_block: Option<&ScopedPatternOverride>,
    profile_name: &str,
) -> (DocInventoryConfig, BlockResolution) {
    match override_block {
        Some(override_block) if override_block.mode == OverrideMode::Replace => (
            DocInventoryConfig {
                include: sorted_unique_patterns(&override_block.include),
                exclude: sorted_unique_patterns(&override_block.exclude),
            },
            BlockResolution {
                origin_kind: ConfigBlockSourceKind::OverrideReplace,
                workspace_profile: Some(profile_name.to_string()),
                mode: Some(override_block.mode.as_str().to_string()),
            },
        ),
        Some(override_block) => {
            let base = inherited.cloned().unwrap_or_default();
            (
                DocInventoryConfig {
                    include: merge_patterns(&base.include, &override_block.include),
                    exclude: merge_patterns(&base.exclude, &override_block.exclude),
                },
                BlockResolution {
                    origin_kind: ConfigBlockSourceKind::OverrideMerge,
                    workspace_profile: Some(profile_name.to_string()),
                    mode: Some(override_block.mode.as_str().to_string()),
                },
            )
        }
        None => match inherited {
            Some(inherited) => (
                inherited.clone(),
                BlockResolution {
                    origin_kind: ConfigBlockSourceKind::WorkspaceProfile,
                    workspace_profile: Some(profile_name.to_string()),
                    mode: None,
                },
            ),
            None => (
                DocInventoryConfig::default(),
                BlockResolution {
                    origin_kind: ConfigBlockSourceKind::Default,
                    workspace_profile: Some(profile_name.to_string()),
                    mode: None,
                },
            ),
        },
    }
}

fn resolve_freshness(
    inherited: Option<&FreshnessConfig>,
    override_block: Option<&FreshnessOverride>,
    profile_name: &str,
) -> Result<(FreshnessConfig, BlockResolution)> {
    match override_block {
        Some(override_block) if override_block.mode != OverrideMode::Replace => bail!(
            "freshness overrides only support `mode: replace` in the first inheritance release"
        ),
        Some(override_block) => Ok((
            FreshnessConfig {
                warn_after_commits: override_block.warn_after_commits,
                warn_after_days: override_block.warn_after_days,
                critical_after_days: override_block.critical_after_days,
            },
            BlockResolution {
                origin_kind: ConfigBlockSourceKind::OverrideReplace,
                workspace_profile: Some(profile_name.to_string()),
                mode: Some(override_block.mode.as_str().to_string()),
            },
        )),
        None => match inherited {
            Some(inherited) => Ok((
                inherited.clone(),
                BlockResolution {
                    origin_kind: ConfigBlockSourceKind::WorkspaceProfile,
                    workspace_profile: Some(profile_name.to_string()),
                    mode: None,
                },
            )),
            None => Ok((
                FreshnessConfig::default(),
                BlockResolution {
                    origin_kind: ConfigBlockSourceKind::Default,
                    workspace_profile: Some(profile_name.to_string()),
                    mode: None,
                },
            )),
        },
    }
}

fn resolve_routing(
    inherited: Option<&RoutingConfig>,
    override_block: Option<&RoutingOverride>,
    profile_name: &str,
) -> (RoutingConfig, BlockResolution) {
    match override_block {
        Some(override_block) if override_block.mode == OverrideMode::Replace => (
            RoutingConfig {
                intents: normalize_routing_intents(&override_block.intents),
            },
            BlockResolution {
                origin_kind: ConfigBlockSourceKind::OverrideReplace,
                workspace_profile: Some(profile_name.to_string()),
                mode: Some(override_block.mode.as_str().to_string()),
            },
        ),
        Some(override_block) => {
            let mut merged = inherited.cloned().unwrap_or_default();
            for (alias, intent) in normalize_routing_intents(&override_block.intents) {
                merged.intents.insert(alias, intent);
            }
            (
                merged,
                BlockResolution {
                    origin_kind: ConfigBlockSourceKind::OverrideMerge,
                    workspace_profile: Some(profile_name.to_string()),
                    mode: Some(override_block.mode.as_str().to_string()),
                },
            )
        }
        None => match inherited {
            Some(inherited) => (
                RoutingConfig {
                    intents: normalize_routing_intents(&inherited.intents),
                },
                BlockResolution {
                    origin_kind: ConfigBlockSourceKind::WorkspaceProfile,
                    workspace_profile: Some(profile_name.to_string()),
                    mode: None,
                },
            ),
            None => (
                RoutingConfig::default(),
                BlockResolution {
                    origin_kind: ConfigBlockSourceKind::Default,
                    workspace_profile: Some(profile_name.to_string()),
                    mode: None,
                },
            ),
        },
    }
}

fn normalize_catalog_config(catalog: &CatalogConfig) -> CatalogConfig {
    CatalogConfig {
        repos: catalog.repos.iter().map(normalize_catalog_repo).collect(),
    }
}

fn normalize_catalog_repo(repo: &CatalogRepo) -> CatalogRepo {
    CatalogRepo {
        id: repo.id.trim().to_string(),
        path: normalize_catalog_repo_path(&repo.path),
        canonical_repo: repo
            .canonical_repo
            .as_ref()
            .map(|value| value.trim().to_string()),
        entry_doc: repo
            .entry_doc
            .as_ref()
            .map(|value| normalize_path(value.trim())),
        branch_policy_doc: repo
            .branch_policy_doc
            .as_ref()
            .map(|value| normalize_path(value.trim())),
        workflow_docs: normalize_exact_doc_pointer_list(&repo.workflow_docs),
        integration_docs: normalize_exact_doc_pointer_list(&repo.integration_docs),
        workspace_integration_required: repo.workspace_integration_required,
    }
}

fn normalize_ownership_config(ownership: &OwnershipConfig) -> OwnershipConfig {
    OwnershipConfig {
        domains: ownership
            .domains
            .iter()
            .map(normalize_ownership_domain)
            .collect(),
    }
}

fn normalize_ownership_domain(domain: &OwnershipDomain) -> OwnershipDomain {
    OwnershipDomain {
        id: domain.id.trim().to_string(),
        paths: OwnershipPaths {
            include: sorted_unique_patterns(&domain.paths.include),
            exclude: sorted_unique_patterns(&domain.paths.exclude),
        },
        owner_repo: domain.owner_repo.trim().to_string(),
        non_owner_repos: domain
            .non_owner_repos
            .iter()
            .map(|repo| repo.trim().to_string())
            .collect::<BTreeSet<_>>()
            .into_iter()
            .collect(),
    }
}

fn normalize_catalog_repo_path(path: &str) -> String {
    let trimmed = path.trim();
    if trimmed == "." {
        ".".into()
    } else {
        normalize_path(trimmed)
    }
}

fn normalize_exact_doc_pointer_list(paths: &[String]) -> Vec<String> {
    paths
        .iter()
        .map(|path| normalize_path(path.trim()))
        .collect::<BTreeSet<_>>()
        .into_iter()
        .collect()
}

fn normalize_routing_intents(
    intents: &BTreeMap<String, RoutingIntent>,
) -> BTreeMap<String, RoutingIntent> {
    intents
        .iter()
        .map(|(alias, intent)| {
            (
                alias.trim().to_string(),
                RoutingIntent {
                    paths: sorted_unique_patterns(&intent.paths),
                },
            )
        })
        .collect()
}

fn sorted_unique_patterns(patterns: &[String]) -> Vec<String> {
    patterns
        .iter()
        .map(|pattern| normalize_path(pattern))
        .collect::<BTreeSet<_>>()
        .into_iter()
        .collect()
}

fn merge_patterns(left: &[String], right: &[String]) -> Vec<String> {
    left.iter()
        .chain(right.iter())
        .map(|pattern| normalize_path(pattern))
        .collect::<BTreeSet<_>>()
        .into_iter()
        .collect()
}

fn workspace_profile_rule_source(root_source: &str, profile_name: &str, rule_id: &str) -> String {
    format!("{root_source}#workspace.profiles.{profile_name}.rules.{rule_id}")
}

fn override_rule_source(config_source: &str, operation: &str, rule_id: &str) -> String {
    format!("{config_source}#overrides.rules.{operation}.{rule_id}")
}

pub fn validate_loaded_rules(loaded_rules: &[LoadedRule]) -> Vec<ConfigValidationProblem> {
    let mut problems = Vec::new();
    let mut rule_sources = BTreeMap::<String, Vec<&LoadedRule>>::new();

    for loaded in loaded_rules {
        if !loaded.rule.id.trim().is_empty() {
            rule_sources
                .entry(loaded.rule.id.clone())
                .or_default()
                .push(loaded);
        }
    }

    for (rule_id, entries) in &rule_sources {
        if entries.len() < 2 {
            continue;
        }

        let all_sources = entries
            .iter()
            .map(|entry| entry.source.as_str())
            .collect::<Vec<_>>();

        for entry in entries {
            let other_sources = all_sources
                .iter()
                .copied()
                .filter(|source| *source != entry.source)
                .collect::<Vec<_>>()
                .join(", ");

            problems.push(ConfigValidationProblem {
                source: entry.source.clone(),
                rule_id: Some(rule_id.clone()),
                message: format!("duplicate rule id `{rule_id}` also found in: {other_sources}"),
            });
        }
    }

    for loaded in loaded_rules {
        validate_rule(loaded, &mut problems);
    }

    problems.sort_by(|left, right| {
        (&left.source, &left.rule_id, &left.message).cmp(&(
            &right.source,
            &right.rule_id,
            &right.message,
        ))
    });
    problems
}

pub fn validate_loaded_coverage_configs(
    loaded_configs: &[LoadedCoverageConfig],
) -> Vec<ConfigValidationProblem> {
    let mut problems = Vec::new();

    for loaded in loaded_configs {
        for (index, pattern) in loaded.coverage.include.iter().enumerate() {
            if let Some(message) = validate_trigger_path(pattern) {
                problems.push(ConfigValidationProblem {
                    source: loaded.source.clone(),
                    rule_id: None,
                    message: format!("coverage.include[{index}] {message}"),
                });
            }
        }

        for (index, pattern) in loaded.coverage.exclude.iter().enumerate() {
            if let Some(message) = validate_trigger_path(pattern) {
                problems.push(ConfigValidationProblem {
                    source: loaded.source.clone(),
                    rule_id: None,
                    message: format!("coverage.exclude[{index}] {message}"),
                });
            }
        }
    }

    problems.sort_by(|left, right| {
        (&left.source, &left.rule_id, &left.message).cmp(&(
            &right.source,
            &right.rule_id,
            &right.message,
        ))
    });

    problems
}

pub fn validate_loaded_freshness_configs(
    loaded_configs: &[LoadedFreshnessConfig],
) -> Vec<ConfigValidationProblem> {
    let mut problems = Vec::new();

    for loaded in loaded_configs {
        if loaded.freshness.warn_after_commits == 0 {
            problems.push(ConfigValidationProblem {
                source: loaded.source.clone(),
                rule_id: None,
                message: "freshness.warn_after_commits must be greater than 0".into(),
            });
        }

        if loaded.freshness.warn_after_days == 0 {
            problems.push(ConfigValidationProblem {
                source: loaded.source.clone(),
                rule_id: None,
                message: "freshness.warn_after_days must be greater than 0".into(),
            });
        }

        if loaded.freshness.critical_after_days == 0 {
            problems.push(ConfigValidationProblem {
                source: loaded.source.clone(),
                rule_id: None,
                message: "freshness.critical_after_days must be greater than 0".into(),
            });
        }

        if loaded.freshness.critical_after_days < loaded.freshness.warn_after_days {
            problems.push(ConfigValidationProblem {
                source: loaded.source.clone(),
                rule_id: None,
                message:
                    "freshness.critical_after_days must be greater than or equal to freshness.warn_after_days"
                        .into(),
            });
        }
    }

    problems.sort_by(|left, right| {
        (&left.source, &left.rule_id, &left.message).cmp(&(
            &right.source,
            &right.rule_id,
            &right.message,
        ))
    });

    problems
}

pub fn validate_loaded_doc_inventory_configs(
    loaded_configs: &[LoadedDocInventoryConfig],
) -> Vec<ConfigValidationProblem> {
    let mut problems = Vec::new();

    for loaded in loaded_configs {
        for (index, pattern) in loaded.doc_inventory.include.iter().enumerate() {
            if let Some(message) = validate_trigger_path(pattern) {
                problems.push(ConfigValidationProblem {
                    source: loaded.source.clone(),
                    rule_id: None,
                    message: format!("docInventory.include[{index}] {message}"),
                });
            }
        }

        for (index, pattern) in loaded.doc_inventory.exclude.iter().enumerate() {
            if let Some(message) = validate_trigger_path(pattern) {
                problems.push(ConfigValidationProblem {
                    source: loaded.source.clone(),
                    rule_id: None,
                    message: format!("docInventory.exclude[{index}] {message}"),
                });
            }
        }
    }

    problems.sort_by(|left, right| {
        (&left.source, &left.rule_id, &left.message).cmp(&(
            &right.source,
            &right.rule_id,
            &right.message,
        ))
    });

    problems
}

pub fn validate_loaded_routing_configs(
    loaded_configs: &[LoadedRoutingConfig],
) -> Vec<ConfigValidationProblem> {
    let mut problems = Vec::new();

    for loaded in loaded_configs {
        for (alias, intent) in &loaded.routing.intents {
            if intent.paths.is_empty() {
                problems.push(ConfigValidationProblem {
                    source: loaded.source.clone(),
                    rule_id: None,
                    message: format!("routing.intents.{alias} must define at least one path"),
                });
            }

            for (index, pattern) in intent.paths.iter().enumerate() {
                if let Some(message) = validate_trigger_path(pattern) {
                    problems.push(ConfigValidationProblem {
                        source: loaded.source.clone(),
                        rule_id: None,
                        message: format!("routing.intents.{alias}.paths[{index}] {message}"),
                    });
                }
            }
        }
    }

    problems.sort_by(|left, right| {
        (&left.source, &left.rule_id, &left.message).cmp(&(
            &right.source,
            &right.rule_id,
            &right.message,
        ))
    });

    problems
}

pub fn validate_loaded_catalog_configs(
    loaded_configs: &[LoadedCatalogConfig],
) -> Vec<ConfigValidationProblem> {
    let mut problems = Vec::new();
    let mut id_sources = BTreeMap::<(String, String), Vec<&LoadedCatalogConfig>>::new();
    let mut path_sources = BTreeMap::<(String, String), Vec<&LoadedCatalogConfig>>::new();
    let mut repo_entries = Vec::<(&LoadedCatalogConfig, &CatalogRepo, String)>::new();

    for loaded in loaded_configs {
        for repo in &loaded.catalog.repos {
            if !repo.id.is_empty() {
                id_sources
                    .entry((loaded.source.clone(), repo.id.clone()))
                    .or_default()
                    .push(loaded);
            }
            if !repo.path.is_empty() {
                let repo_root = normalized_catalog_repo_root(&loaded.base_dir, &repo.path);
                path_sources
                    .entry((loaded.source.clone(), repo_root.clone()))
                    .or_default()
                    .push(loaded);
                repo_entries.push((loaded, repo, repo_root));
            } else {
                repo_entries.push((loaded, repo, String::new()));
            }
        }
    }

    for ((_, repo_id), entries) in id_sources {
        if entries.len() < 2 {
            continue;
        }

        for entry in entries {
            problems.push(ConfigValidationProblem {
                source: entry.source.clone(),
                rule_id: None,
                message: format!("catalog repo id `{repo_id}` is duplicated in config"),
            });
        }
    }

    for ((_, repo_path), entries) in path_sources {
        if entries.len() < 2 {
            continue;
        }

        for entry in entries {
            problems.push(ConfigValidationProblem {
                source: entry.source.clone(),
                rule_id: None,
                message: format!("catalog repo path `{repo_path}` is duplicated in config"),
            });
        }
    }

    for left_index in 0..repo_entries.len() {
        for right_index in (left_index + 1)..repo_entries.len() {
            let (left_loaded, left_repo, ref left_root) = repo_entries[left_index];
            let (right_loaded, right_repo, ref right_root) = repo_entries[right_index];
            if left_root == right_root {
                continue;
            }
            if !catalog_repo_paths_overlap(left_root, right_root) {
                continue;
            }

            problems.push(ConfigValidationProblem {
                source: left_loaded.source.clone(),
                rule_id: None,
                message: format!(
                    "catalog repo path `{}` overlaps with `{}` from {}",
                    left_repo.path, right_repo.path, right_loaded.source
                ),
            });
            problems.push(ConfigValidationProblem {
                source: right_loaded.source.clone(),
                rule_id: None,
                message: format!(
                    "catalog repo path `{}` overlaps with `{}` from {}",
                    right_repo.path, left_repo.path, left_loaded.source
                ),
            });
        }
    }

    problems.sort_by(|left, right| {
        (&left.source, &left.rule_id, &left.message).cmp(&(
            &right.source,
            &right.rule_id,
            &right.message,
        ))
    });

    problems
}

pub fn validate_loaded_ownership_configs(
    loaded_configs: &[LoadedOwnershipConfig],
    catalog_configs: &[LoadedCatalogConfig],
) -> Vec<ConfigValidationProblem> {
    let mut problems = Vec::new();
    let mut domain_sources = BTreeMap::<(String, String), Vec<&LoadedOwnershipConfig>>::new();
    let mut catalog_repo_ids_by_source = BTreeMap::<String, BTreeSet<String>>::new();
    let mut workspace_catalog_repo_ids = BTreeSet::<String>::new();

    for loaded_catalog in catalog_configs {
        for repo in &loaded_catalog.catalog.repos {
            if !repo.id.is_empty() {
                catalog_repo_ids_by_source
                    .entry(loaded_catalog.source.clone())
                    .or_default()
                    .insert(repo.id.clone());
                if loaded_catalog.scope_kind == ConfigScopeKind::WorkspaceRoot {
                    workspace_catalog_repo_ids.insert(repo.id.clone());
                }
            }
        }
    }

    for loaded in loaded_configs {
        for domain in &loaded.ownership.domains {
            if !domain.id.is_empty() {
                domain_sources
                    .entry((loaded.source.clone(), domain.id.clone()))
                    .or_default()
                    .push(loaded);
            }

            if !domain.owner_repo.is_empty()
                && !catalog_repo_reference_exists(
                    loaded,
                    &domain.owner_repo,
                    &catalog_repo_ids_by_source,
                    &workspace_catalog_repo_ids,
                )
            {
                problems.push(ConfigValidationProblem {
                    source: loaded.source.clone(),
                    rule_id: None,
                    message: format!(
                        "ownership domain `{}` references unknown ownerRepo `{}`",
                        domain.id, domain.owner_repo
                    ),
                });
            }

            for repo_id in &domain.non_owner_repos {
                if !catalog_repo_reference_exists(
                    loaded,
                    repo_id,
                    &catalog_repo_ids_by_source,
                    &workspace_catalog_repo_ids,
                ) {
                    problems.push(ConfigValidationProblem {
                        source: loaded.source.clone(),
                        rule_id: None,
                        message: format!(
                            "ownership domain `{}` references unknown nonOwnerRepo `{}`",
                            domain.id, repo_id
                        ),
                    });
                }
            }
        }
    }

    for ((_, domain_id), entries) in domain_sources {
        if entries.len() < 2 {
            continue;
        }

        for entry in entries {
            problems.push(ConfigValidationProblem {
                source: entry.source.clone(),
                rule_id: None,
                message: format!("ownership domain id `{domain_id}` is duplicated in config"),
            });
        }
    }

    problems.sort_by(|left, right| {
        (&left.source, &left.rule_id, &left.message).cmp(&(
            &right.source,
            &right.rule_id,
            &right.message,
        ))
    });

    problems
}

fn catalog_repo_reference_exists(
    loaded: &LoadedOwnershipConfig,
    repo_id: &str,
    catalog_repo_ids_by_source: &BTreeMap<String, BTreeSet<String>>,
    workspace_catalog_repo_ids: &BTreeSet<String>,
) -> bool {
    catalog_repo_ids_by_source
        .get(&loaded.source)
        .is_some_and(|repo_ids| repo_ids.contains(repo_id))
        || workspace_catalog_repo_ids.contains(repo_id)
}

pub fn analyze_ownership_paths(
    tracked_paths: &[String],
    loaded_configs: &[LoadedOwnershipConfig],
) -> OwnershipAnalysis {
    let mut analyses = Vec::new();
    let mut conflicts = Vec::new();
    let mut overlaps = Vec::new();

    for tracked_path in tracked_paths {
        let mut matches = Vec::<OwnershipDomainMatch>::new();

        for loaded in loaded_configs {
            for domain in &loaded.ownership.domains {
                let matched_include = domain
                    .paths
                    .include
                    .iter()
                    .map(|pattern| resolve_rule_path(&loaded.base_dir, pattern))
                    .filter(|pattern| crate::rules::matches_pattern(tracked_path, pattern))
                    .max_by(ownership_pattern_specificity_cmp);

                let Some(matched_include) = matched_include else {
                    continue;
                };

                let excluded = domain
                    .paths
                    .exclude
                    .iter()
                    .map(|pattern| resolve_rule_path(&loaded.base_dir, pattern))
                    .any(|pattern| crate::rules::matches_pattern(tracked_path, &pattern));
                if excluded {
                    continue;
                }

                let (static_prefix_len, wildcard_count) =
                    ownership_pattern_specificity(&matched_include);

                matches.push(OwnershipDomainMatch {
                    source: loaded.source.clone(),
                    base_dir: loaded.base_dir.clone(),
                    domain_id: domain.id.clone(),
                    owner_repo: domain.owner_repo.clone(),
                    non_owner_repos: domain.non_owner_repos.clone(),
                    matched_include,
                    static_prefix_len,
                    wildcard_count,
                });
            }
        }

        if matches.is_empty() {
            continue;
        }

        matches.sort_by(ownership_domain_match_cmp);
        let selected = matches[0].clone();
        let owner_repos = matches
            .iter()
            .map(|entry| entry.owner_repo.clone())
            .collect::<BTreeSet<_>>()
            .into_iter()
            .collect::<Vec<_>>();
        let domain_ids = matches
            .iter()
            .map(|entry| entry.domain_id.clone())
            .collect::<BTreeSet<_>>()
            .into_iter()
            .collect::<Vec<_>>();
        let has_conflict = owner_repos.len() > 1;
        let has_overlap = !has_conflict && matches.len() > 1;

        if has_conflict {
            conflicts.push(OwnershipConflict {
                path: tracked_path.clone(),
                owner_repos,
                domain_ids,
            });
        } else if has_overlap {
            overlaps.push(OwnershipOverlap {
                path: tracked_path.clone(),
                owner_repo: selected.owner_repo.clone(),
                domain_ids,
                selected_domain_id: selected.domain_id.clone(),
            });
        }

        analyses.push(OwnershipPathAnalysis {
            path: tracked_path.clone(),
            matches,
            selected,
            has_conflict,
            has_overlap,
        });
    }

    OwnershipAnalysis {
        paths: analyses,
        conflicts,
        overlaps,
    }
}

pub fn validate_ownership_path_conflicts(
    analysis: &OwnershipAnalysis,
) -> Vec<ConfigValidationProblem> {
    let mut problems = Vec::new();

    for path_analysis in &analysis.paths {
        if !path_analysis.has_conflict {
            continue;
        }

        let conflicting_owners = path_analysis
            .matches
            .iter()
            .map(|entry| entry.owner_repo.clone())
            .collect::<BTreeSet<_>>()
            .into_iter()
            .collect::<Vec<_>>()
            .join(", ");

        for entry in &path_analysis.matches {
            problems.push(ConfigValidationProblem {
                source: entry.source.clone(),
                rule_id: None,
                message: format!(
                    "ownership conflict: tracked path `{}` matches domain `{}` (ownerRepo `{}`) but conflicting ownerRepos are present: {}",
                    path_analysis.path,
                    entry.domain_id,
                    entry.owner_repo,
                    conflicting_owners
                ),
            });
        }
    }

    problems.sort_by(|left, right| {
        (&left.source, &left.rule_id, &left.message).cmp(&(
            &right.source,
            &right.rule_id,
            &right.message,
        ))
    });

    problems
}

fn validate_parsed_configs(parsed_files: &[ParsedImpactFile]) -> Vec<ConfigValidationProblem> {
    let mut problems = Vec::new();

    let root_workspace_profiles = parsed_files
        .first()
        .and_then(|parsed| match parsed.parsed.layout {
            ConfigLayout::Workspace => parsed.parsed.workspace.as_ref(),
            ConfigLayout::Repo => None,
        })
        .map(|workspace| workspace.profiles.clone())
        .unwrap_or_default();

    for parsed in parsed_files {
        validate_top_level_blocks(parsed, &mut problems);
        match parsed.parsed.layout {
            ConfigLayout::Workspace => {
                validate_workspace_config(parsed, &mut problems);
            }
            ConfigLayout::Repo => {
                validate_repo_config(parsed, &root_workspace_profiles, &mut problems);
            }
        }
    }

    problems.sort_by(|left, right| {
        (&left.source, &left.rule_id, &left.message).cmp(&(
            &right.source,
            &right.rule_id,
            &right.message,
        ))
    });
    problems
}

fn validate_top_level_blocks(
    parsed: &ParsedImpactFile,
    problems: &mut Vec<ConfigValidationProblem>,
) {
    if let Some(coverage) = parsed.parsed.coverage.as_ref() {
        validate_scoped_patterns(
            &parsed.descriptor.rel_path,
            "coverage",
            &coverage.include,
            &coverage.exclude,
            problems,
        );
    }

    if let Some(doc_inventory) = parsed.parsed.doc_inventory.as_ref() {
        validate_scoped_patterns(
            &parsed.descriptor.rel_path,
            "docInventory",
            &doc_inventory.include,
            &doc_inventory.exclude,
            problems,
        );
    }

    if let Some(freshness) = parsed.parsed.freshness.as_ref() {
        validate_freshness_block(&parsed.descriptor.rel_path, freshness, problems);
    }

    if let Some(routing) = parsed.parsed.routing.as_ref() {
        validate_routing_block(&parsed.descriptor.rel_path, &routing.intents, problems);
    }

    if let Some(catalog) = parsed.parsed.catalog.as_ref() {
        validate_catalog_block(&parsed.descriptor.rel_path, &catalog.repos, problems);
    }

    if let Some(ownership) = parsed.parsed.ownership.as_ref() {
        validate_ownership_block(&parsed.descriptor.rel_path, &ownership.domains, problems);
    }

    for rule in &parsed.parsed.rules {
        validate_single_rule(
            rule,
            &parsed.descriptor.rel_path,
            &parsed.descriptor.base_dir,
            problems,
        );
    }
}

fn validate_workspace_config(
    parsed: &ParsedImpactFile,
    problems: &mut Vec<ConfigValidationProblem>,
) {
    if parsed.parsed.inherit.is_some() {
        problems.push(ConfigValidationProblem {
            source: parsed.descriptor.rel_path.clone(),
            rule_id: None,
            message: "workspace root configs must not declare `inherit`".into(),
        });
    }
    if parsed.parsed.overrides.is_some() {
        problems.push(ConfigValidationProblem {
            source: parsed.descriptor.rel_path.clone(),
            rule_id: None,
            message: "workspace root configs must not declare `overrides`".into(),
        });
    }

    if let Some(workspace) = parsed.parsed.workspace.as_ref() {
        for (profile_name, profile) in &workspace.profiles {
            let profile_source = format!(
                "{}#workspace.profiles.{profile_name}",
                parsed.descriptor.rel_path
            );
            if let Some(coverage) = profile.coverage.as_ref() {
                validate_scoped_patterns(
                    &profile_source,
                    "coverage",
                    &coverage.include,
                    &coverage.exclude,
                    problems,
                );
            }
            if let Some(doc_inventory) = profile.doc_inventory.as_ref() {
                validate_scoped_patterns(
                    &profile_source,
                    "docInventory",
                    &doc_inventory.include,
                    &doc_inventory.exclude,
                    problems,
                );
            }
            if let Some(freshness) = profile.freshness.as_ref() {
                validate_freshness_block(&profile_source, freshness, problems);
            }
            if let Some(routing) = profile.routing.as_ref() {
                validate_routing_block(&profile_source, &routing.intents, problems);
            }
            for rule in &profile.rules {
                validate_single_rule(rule, &profile_source, &parsed.descriptor.base_dir, problems);
            }
        }
    }
}

fn validate_repo_config(
    parsed: &ParsedImpactFile,
    root_workspace_profiles: &BTreeMap<String, WorkspaceProfile>,
    problems: &mut Vec<ConfigValidationProblem>,
) {
    let Some(inherit) = parsed.parsed.inherit.as_ref() else {
        if parsed.parsed.overrides.is_some() {
            problems.push(ConfigValidationProblem {
                source: parsed.descriptor.rel_path.clone(),
                rule_id: None,
                message: "`overrides` requires `inherit.workspace_profile`".into(),
            });
        }
        return;
    };

    if parsed.parsed.coverage.is_some() {
        problems.push(ConfigValidationProblem {
            source: parsed.descriptor.rel_path.clone(),
            rule_id: None,
            message: "configs using workspace inheritance must not define top-level `coverage`; move it into `overrides.coverage`".into(),
        });
    }
    if parsed.parsed.doc_inventory.is_some() {
        problems.push(ConfigValidationProblem {
            source: parsed.descriptor.rel_path.clone(),
            rule_id: None,
            message: "configs using workspace inheritance must not define top-level `docInventory`; move it into `overrides.docInventory`".into(),
        });
    }
    if parsed.parsed.freshness.is_some() {
        problems.push(ConfigValidationProblem {
            source: parsed.descriptor.rel_path.clone(),
            rule_id: None,
            message: "configs using workspace inheritance must not define top-level `freshness`; move it into `overrides.freshness`".into(),
        });
    }
    if parsed.parsed.routing.is_some() {
        problems.push(ConfigValidationProblem {
            source: parsed.descriptor.rel_path.clone(),
            rule_id: None,
            message: "configs using workspace inheritance must not define top-level `routing`; move it into `overrides.routing`".into(),
        });
    }
    if !parsed.parsed.rules.is_empty() {
        problems.push(ConfigValidationProblem {
            source: parsed.descriptor.rel_path.clone(),
            rule_id: None,
            message: "configs using workspace inheritance must not define top-level `rules`; use `overrides.rules`".into(),
        });
    }

    let Some(profile) = root_workspace_profiles.get(&inherit.workspace_profile) else {
        problems.push(ConfigValidationProblem {
            source: parsed.descriptor.rel_path.clone(),
            rule_id: None,
            message: format!(
                "inherit.workspace_profile `{}` does not exist in the active workspace root",
                inherit.workspace_profile
            ),
        });
        return;
    };

    let overrides = parsed.parsed.overrides.clone().unwrap_or_default();
    if let Some(coverage) = overrides.coverage.as_ref() {
        validate_scoped_patterns(
            &format!("{}#overrides.coverage", parsed.descriptor.rel_path),
            "coverage",
            &coverage.include,
            &coverage.exclude,
            problems,
        );
    }
    if let Some(doc_inventory) = overrides.doc_inventory.as_ref() {
        validate_scoped_patterns(
            &format!("{}#overrides.docInventory", parsed.descriptor.rel_path),
            "docInventory",
            &doc_inventory.include,
            &doc_inventory.exclude,
            problems,
        );
    }
    if let Some(freshness) = overrides.freshness.as_ref() {
        if freshness.mode != OverrideMode::Replace {
            problems.push(ConfigValidationProblem {
                source: format!("{}#overrides.freshness", parsed.descriptor.rel_path),
                rule_id: None,
                message: "overrides.freshness only supports `mode: replace`".into(),
            });
        }
        validate_freshness_block(
            &format!("{}#overrides.freshness", parsed.descriptor.rel_path),
            &FreshnessConfig {
                warn_after_commits: freshness.warn_after_commits,
                warn_after_days: freshness.warn_after_days,
                critical_after_days: freshness.critical_after_days,
            },
            problems,
        );
    }
    if let Some(routing) = overrides.routing.as_ref() {
        validate_routing_block(
            &format!("{}#overrides.routing", parsed.descriptor.rel_path),
            &routing.intents,
            problems,
        );
    }

    for rule in &overrides.rules.add {
        validate_single_rule(
            rule,
            &format!("{}#overrides.rules.add", parsed.descriptor.rel_path),
            &parsed.descriptor.base_dir,
            problems,
        );
    }
    for rule in &overrides.rules.replace {
        validate_single_rule(
            rule,
            &format!("{}#overrides.rules.replace", parsed.descriptor.rel_path),
            &parsed.descriptor.base_dir,
            problems,
        );
    }

    let profile_rule_ids = profile
        .rules
        .iter()
        .map(|rule| rule.id.as_str())
        .collect::<BTreeSet<_>>();
    let replace_ids = overrides
        .rules
        .replace
        .iter()
        .map(|rule| rule.id.as_str())
        .collect::<BTreeSet<_>>();
    let add_ids = overrides
        .rules
        .add
        .iter()
        .map(|rule| rule.id.as_str())
        .collect::<BTreeSet<_>>();

    for rule in &overrides.rules.replace {
        if !profile_rule_ids.contains(rule.id.as_str()) {
            problems.push(ConfigValidationProblem {
                source: format!("{}#overrides.rules.replace", parsed.descriptor.rel_path),
                rule_id: Some(rule.id.clone()),
                message: format!(
                    "override replace rule `{}` does not match any inherited rule in workspace profile `{}`",
                    rule.id, inherit.workspace_profile
                ),
            });
        }
    }

    for disabled in &overrides.rules.disable {
        if !profile_rule_ids.contains(disabled.id.as_str()) {
            problems.push(ConfigValidationProblem {
                source: format!("{}#overrides.rules.disable", parsed.descriptor.rel_path),
                rule_id: Some(disabled.id.clone()),
                message: format!(
                    "override disable rule `{}` does not match any inherited rule in workspace profile `{}`",
                    disabled.id, inherit.workspace_profile
                ),
            });
        }
        if add_ids.contains(disabled.id.as_str()) && !replace_ids.contains(disabled.id.as_str()) {
            problems.push(ConfigValidationProblem {
                source: format!("{}#overrides.rules.disable", parsed.descriptor.rel_path),
                rule_id: Some(disabled.id.clone()),
                message: format!(
                    "override disable rule `{}` targets a locally added rule; only inherited rules may be disabled",
                    disabled.id
                ),
            });
        }
        if disabled.reason.trim().is_empty() {
            problems.push(ConfigValidationProblem {
                source: format!("{}#overrides.rules.disable", parsed.descriptor.rel_path),
                rule_id: Some(disabled.id.clone()),
                message: "override disable entries must include a non-empty reason".into(),
            });
        }
    }
}

fn validate_scoped_patterns(
    source: &str,
    block_name: &str,
    include: &[String],
    exclude: &[String],
    problems: &mut Vec<ConfigValidationProblem>,
) {
    for (index, pattern) in include.iter().enumerate() {
        if let Some(message) = validate_trigger_path(pattern) {
            problems.push(ConfigValidationProblem {
                source: source.into(),
                rule_id: None,
                message: format!("{block_name}.include[{index}] {message}"),
            });
        }
    }

    for (index, pattern) in exclude.iter().enumerate() {
        if let Some(message) = validate_trigger_path(pattern) {
            problems.push(ConfigValidationProblem {
                source: source.into(),
                rule_id: None,
                message: format!("{block_name}.exclude[{index}] {message}"),
            });
        }
    }
}

fn validate_freshness_block(
    source: &str,
    freshness: &FreshnessConfig,
    problems: &mut Vec<ConfigValidationProblem>,
) {
    if freshness.warn_after_commits == 0 {
        problems.push(ConfigValidationProblem {
            source: source.into(),
            rule_id: None,
            message: "freshness.warn_after_commits must be greater than 0".into(),
        });
    }
    if freshness.warn_after_days == 0 {
        problems.push(ConfigValidationProblem {
            source: source.into(),
            rule_id: None,
            message: "freshness.warn_after_days must be greater than 0".into(),
        });
    }
    if freshness.critical_after_days == 0 {
        problems.push(ConfigValidationProblem {
            source: source.into(),
            rule_id: None,
            message: "freshness.critical_after_days must be greater than 0".into(),
        });
    }
    if freshness.critical_after_days < freshness.warn_after_days {
        problems.push(ConfigValidationProblem {
            source: source.into(),
            rule_id: None,
            message:
                "freshness.critical_after_days must be greater than or equal to freshness.warn_after_days"
                    .into(),
        });
    }
}

fn validate_routing_block(
    source: &str,
    intents: &BTreeMap<String, RoutingIntent>,
    problems: &mut Vec<ConfigValidationProblem>,
) {
    for (alias, intent) in intents {
        if alias.trim().is_empty() {
            problems.push(ConfigValidationProblem {
                source: source.into(),
                rule_id: None,
                message: "routing intent alias must not be empty".into(),
            });
        }

        if intent.paths.is_empty() {
            problems.push(ConfigValidationProblem {
                source: source.into(),
                rule_id: None,
                message: format!("routing.intents.{alias} must define at least one path"),
            });
        }

        for (index, pattern) in intent.paths.iter().enumerate() {
            if let Some(message) = validate_trigger_path(pattern) {
                problems.push(ConfigValidationProblem {
                    source: source.into(),
                    rule_id: None,
                    message: format!("routing.intents.{alias}.paths[{index}] {message}"),
                });
            }
        }
    }
}

fn validate_catalog_block(
    source: &str,
    repos: &[CatalogRepo],
    problems: &mut Vec<ConfigValidationProblem>,
) {
    if repos.is_empty() {
        problems.push(ConfigValidationProblem {
            source: source.into(),
            rule_id: None,
            message: "catalog.repos must define at least one repo".into(),
        });
        return;
    }

    for (index, repo) in repos.iter().enumerate() {
        if repo.id.trim().is_empty() {
            problems.push(ConfigValidationProblem {
                source: source.into(),
                rule_id: None,
                message: format!("catalog.repos[{index}].id must not be empty"),
            });
        }

        if let Some(message) = validate_catalog_repo_path(&repo.path) {
            problems.push(ConfigValidationProblem {
                source: source.into(),
                rule_id: None,
                message: format!("catalog.repos[{index}].path {message}"),
            });
        }

        if let Some(canonical_repo) = repo.canonical_repo.as_ref()
            && canonical_repo.trim().is_empty()
        {
            problems.push(ConfigValidationProblem {
                source: source.into(),
                rule_id: None,
                message: format!(
                    "catalog.repos[{index}].canonicalRepo must not be empty when provided"
                ),
            });
        }

        if let Some(path) = repo.entry_doc.as_ref()
            && let Some(message) = validate_required_doc_path(path)
        {
            problems.push(ConfigValidationProblem {
                source: source.into(),
                rule_id: None,
                message: format!("catalog.repos[{index}].entryDoc {message}"),
            });
        }

        if let Some(path) = repo.branch_policy_doc.as_ref()
            && let Some(message) = validate_required_doc_path(path)
        {
            problems.push(ConfigValidationProblem {
                source: source.into(),
                rule_id: None,
                message: format!("catalog.repos[{index}].branchPolicyDoc {message}"),
            });
        }

        for (doc_index, path) in repo.workflow_docs.iter().enumerate() {
            if let Some(message) = validate_required_doc_path(path) {
                problems.push(ConfigValidationProblem {
                    source: source.into(),
                    rule_id: None,
                    message: format!("catalog.repos[{index}].workflowDocs[{doc_index}] {message}"),
                });
            }
        }

        for (doc_index, path) in repo.integration_docs.iter().enumerate() {
            if let Some(message) = validate_required_doc_path(path) {
                problems.push(ConfigValidationProblem {
                    source: source.into(),
                    rule_id: None,
                    message: format!(
                        "catalog.repos[{index}].integrationDocs[{doc_index}] {message}"
                    ),
                });
            }
        }
    }
}

fn validate_ownership_block(
    source: &str,
    domains: &[OwnershipDomain],
    problems: &mut Vec<ConfigValidationProblem>,
) {
    if domains.is_empty() {
        problems.push(ConfigValidationProblem {
            source: source.into(),
            rule_id: None,
            message: "ownership.domains must define at least one domain".into(),
        });
        return;
    }

    for (index, domain) in domains.iter().enumerate() {
        if domain.id.trim().is_empty() {
            problems.push(ConfigValidationProblem {
                source: source.into(),
                rule_id: None,
                message: format!("ownership.domains[{index}].id must not be empty"),
            });
        }

        if domain.paths.include.is_empty() {
            problems.push(ConfigValidationProblem {
                source: source.into(),
                rule_id: None,
                message: format!(
                    "ownership.domains[{index}].paths.include must define at least one path"
                ),
            });
        }

        for (path_index, pattern) in domain.paths.include.iter().enumerate() {
            if let Some(message) = validate_trigger_path(pattern) {
                problems.push(ConfigValidationProblem {
                    source: source.into(),
                    rule_id: None,
                    message: format!(
                        "ownership.domains[{index}].paths.include[{path_index}] {message}"
                    ),
                });
            }
        }

        for (path_index, pattern) in domain.paths.exclude.iter().enumerate() {
            if let Some(message) = validate_trigger_path(pattern) {
                problems.push(ConfigValidationProblem {
                    source: source.into(),
                    rule_id: None,
                    message: format!(
                        "ownership.domains[{index}].paths.exclude[{path_index}] {message}"
                    ),
                });
            }
        }

        if domain.owner_repo.trim().is_empty() {
            problems.push(ConfigValidationProblem {
                source: source.into(),
                rule_id: None,
                message: format!("ownership.domains[{index}].ownerRepo must not be empty"),
            });
        }

        if domain.non_owner_repos.contains(&domain.owner_repo) {
            problems.push(ConfigValidationProblem {
                source: source.into(),
                rule_id: None,
                message: format!(
                    "ownership domain `{}` must not list ownerRepo `{}` inside nonOwnerRepos",
                    domain.id, domain.owner_repo
                ),
            });
        }

        for (repo_index, repo_id) in domain.non_owner_repos.iter().enumerate() {
            if repo_id.trim().is_empty() {
                problems.push(ConfigValidationProblem {
                    source: source.into(),
                    rule_id: None,
                    message: format!(
                        "ownership.domains[{index}].nonOwnerRepos[{repo_index}] must not be empty"
                    ),
                });
            }
        }
    }
}

fn validate_single_rule(
    rule: &Rule,
    source: &str,
    base_dir: &str,
    problems: &mut Vec<ConfigValidationProblem>,
) {
    let loaded = LoadedRule {
        source: source.into(),
        config_source: source.into(),
        base_dir: base_dir.into(),
        provenance: RuleProvenance {
            config_source: source.into(),
            origin_kind: RuleOriginKind::RepoLocal,
            workspace_profile: None,
        },
        rule: rule.clone(),
    };
    validate_rule(&loaded, problems);
}

fn validate_rule(loaded: &LoadedRule, problems: &mut Vec<ConfigValidationProblem>) {
    let rule = &loaded.rule;
    let rule_id = if rule.id.trim().is_empty() {
        None
    } else {
        Some(rule.id.clone())
    };

    if rule.id.trim().is_empty() {
        push_problem(problems, loaded, None, "rule id must not be empty".into());
    }

    if rule.triggers.is_empty() {
        push_problem(
            problems,
            loaded,
            rule_id.clone(),
            "rule must define at least one trigger".into(),
        );
    }

    if rule.required_docs.is_empty() {
        push_problem(
            problems,
            loaded,
            rule_id.clone(),
            "rule must define at least one required doc".into(),
        );
    }

    for (index, trigger) in rule.triggers.iter().enumerate() {
        if let Some(message) = validate_trigger_path(&trigger.path) {
            push_problem(
                problems,
                loaded,
                rule_id.clone(),
                format!("triggers[{index}].path {message}"),
            );
        }
    }

    for (index, doc) in rule.required_docs.iter().enumerate() {
        if let Some(message) = validate_required_doc_path(&doc.path) {
            push_problem(
                problems,
                loaded,
                rule_id.clone(),
                format!("requiredDocs[{index}].path {message}"),
            );
        }

        if let Some(mode) = &doc.mode {
            if !SUPPORTED_REQUIRED_DOC_MODES.contains(&mode.as_str()) {
                push_problem(
                    problems,
                    loaded,
                    rule_id.clone(),
                    format!(
                        "requiredDocs[{index}].mode `{mode}` is invalid; expected one of: {}",
                        SUPPORTED_REQUIRED_DOC_MODES.join(", ")
                    ),
                );
            }
        }
    }
}

fn push_problem(
    problems: &mut Vec<ConfigValidationProblem>,
    loaded: &LoadedRule,
    rule_id: Option<String>,
    message: String,
) {
    problems.push(ConfigValidationProblem {
        source: loaded.source.clone(),
        rule_id,
        message,
    });
}

fn validate_trigger_path(path: &str) -> Option<String> {
    validate_repo_relative_path(path, true)
}

fn validate_required_doc_path(path: &str) -> Option<String> {
    let base_message = validate_repo_relative_path(path, false)?;
    Some(base_message)
}

fn validate_catalog_repo_path(path: &str) -> Option<String> {
    let trimmed = path.trim();

    if trimmed == "." {
        return None;
    }

    validate_repo_relative_path(trimmed, false)
}

fn catalog_repo_paths_overlap(left: &str, right: &str) -> bool {
    if left == "." || right == "." {
        return true;
    }

    left == right
        || left
            .strip_prefix(right)
            .is_some_and(|suffix| suffix.starts_with('/'))
        || right
            .strip_prefix(left)
            .is_some_and(|suffix| suffix.starts_with('/'))
}

fn ownership_pattern_specificity(pattern: &str) -> (usize, usize) {
    let static_prefix_len = pattern
        .chars()
        .take_while(|ch| *ch != '*' && *ch != '?')
        .count();
    let wildcard_count = pattern.chars().filter(|ch| matches!(ch, '*' | '?')).count();
    (static_prefix_len, wildcard_count)
}

fn ownership_pattern_specificity_cmp(left: &String, right: &String) -> std::cmp::Ordering {
    let left_specificity = ownership_pattern_specificity(left);
    let right_specificity = ownership_pattern_specificity(right);

    right_specificity
        .0
        .cmp(&left_specificity.0)
        .then_with(|| left_specificity.1.cmp(&right_specificity.1))
        .then_with(|| left.cmp(right))
}

fn ownership_domain_match_cmp(
    left: &OwnershipDomainMatch,
    right: &OwnershipDomainMatch,
) -> std::cmp::Ordering {
    right
        .static_prefix_len
        .cmp(&left.static_prefix_len)
        .then_with(|| left.wildcard_count.cmp(&right.wildcard_count))
        .then_with(|| left.domain_id.cmp(&right.domain_id))
        .then_with(|| left.source.cmp(&right.source))
}

fn validate_repo_relative_path(path: &str, allow_glob: bool) -> Option<String> {
    let trimmed = path.trim();

    if trimmed.is_empty() {
        return Some("must not be empty".into());
    }

    if trimmed.starts_with('/') || looks_like_windows_absolute_path(trimmed) {
        return Some(format!("must be repo-relative, got `{trimmed}`"));
    }

    if trimmed.contains('\\') {
        return Some("must use `/` separators".into());
    }

    let segments = trimmed.split('/').collect::<Vec<_>>();
    if segments.iter().any(|segment| segment.is_empty()) {
        return Some("must not contain empty path segments".into());
    }

    for segment in segments {
        if matches!(segment, "." | "..") {
            return Some("must not contain `.` or `..` segments".into());
        }

        if allow_glob {
            if segment.contains("**") && segment != "**" {
                return Some(format!(
                    "contains malformed glob segment `{segment}`; `**` must occupy a full path segment"
                ));
            }
        } else if segment.contains('*') || segment.contains('?') {
            return Some("must be an exact document path, not a glob".into());
        }
    }

    None
}

fn looks_like_windows_absolute_path(path: &str) -> bool {
    let bytes = path.as_bytes();
    bytes.len() >= 3
        && bytes[1] == b':'
        && bytes[0].is_ascii_alphabetic()
        && matches!(bytes[2], b'/' | b'\\')
}

pub fn resolve_rule_path(base_dir: &str, rel_pattern: &str) -> String {
    let rel_pattern = normalize_path(rel_pattern);
    if base_dir.is_empty() {
        return rel_pattern;
    }
    normalize_path(&format!("{base_dir}/{rel_pattern}"))
}

pub fn root_dir_from_option(root: Option<&Path>) -> Result<PathBuf> {
    match root {
        Some(path) => Ok(path.to_path_buf()),
        None => std::env::current_dir().into_diagnostic(),
    }
}

pub fn require_existing_path(path: &Path) -> Result<()> {
    if path.exists() {
        Ok(())
    } else {
        bail!("Path does not exist: {}", path.display())
    }
}

#[cfg(test)]
mod tests {
    use std::fs;
    use std::path::PathBuf;
    use std::time::{SystemTime, UNIX_EPOCH};

    use super::{
        CONFIG_FILE, DOC_ROOT_DIR, ImpactLayout, RuleOriginKind, analyze_ownership_paths,
        detect_impact_layout, load_catalog_configs, load_effective_configs, load_impact_files,
        load_ownership_configs, load_routing_configs, normalize_path, resolve_rule_path,
        root_dir_from_option, validate_config_graph, validate_loaded_catalog_configs,
        validate_loaded_ownership_configs, validate_loaded_routing_configs, validate_loaded_rules,
        validate_ownership_path_conflicts,
    };

    fn temp_dir(prefix: &str) -> PathBuf {
        let nanos = SystemTime::now()
            .duration_since(UNIX_EPOCH)
            .expect("system time should be valid")
            .as_nanos();
        let path = std::env::temp_dir().join(format!("{prefix}-{nanos}-{}", std::process::id()));
        fs::create_dir_all(&path).expect("temp dir should be created");
        path
    }

    #[test]
    fn normalize_path_supports_repo_relative_matching() {
        assert_eq!(
            normalize_path(".\\tiangong-lca-next\\config\\routes.ts"),
            "tiangong-lca-next/config/routes.ts"
        );
    }

    #[test]
    fn detect_impact_layout_distinguishes_workspace_and_repo_roots() {
        let root = temp_dir("docpact-layout");
        fs::create_dir_all(root.join(DOC_ROOT_DIR)).expect("doc root dir should exist");

        assert_eq!(
            detect_impact_layout(&root, None).expect("layout should resolve"),
            ImpactLayout::None
        );

        fs::write(
            root.join(CONFIG_FILE),
            "version: 1\nlayout: repo\nlastReviewedAt: 2026-04-18\nlastReviewedCommit: abc\n",
        )
        .expect("repo config");
        assert_eq!(
            detect_impact_layout(&root, None).expect("layout should resolve"),
            ImpactLayout::Repo
        );

        fs::write(
            root.join(CONFIG_FILE),
            "version: 1\nlayout: workspace\nlastReviewedAt: 2026-04-18\nlastReviewedCommit: abc\n",
        )
        .expect("workspace config");
        assert_eq!(
            detect_impact_layout(&root, None).expect("layout should resolve"),
            ImpactLayout::Workspace
        );
    }

    #[test]
    fn workspace_inheritance_builds_effective_child_config() {
        let root = temp_dir("docpact-config-inherit");
        fs::create_dir_all(root.join(DOC_ROOT_DIR)).expect("root doc dir");
        fs::create_dir_all(root.join(format!("sample-sdk/{DOC_ROOT_DIR}")))
            .expect("subrepo doc dir");

        fs::write(
            root.join(CONFIG_FILE),
            r#"
version: 1
layout: workspace
workspace:
  name: demo
  profiles:
    default:
      coverage:
        include:
          - src/**
      docInventory:
        include:
          - docs/**
      freshness:
        warn_after_commits: 10
        warn_after_days: 20
        critical_after_days: 30
      routing:
        intents:
          shared:
            paths:
              - src/**
      rules:
        - id: inherited-rule
          scope: workspace
          repo: workspace
          triggers:
            - path: src/**
              kind: code
          requiredDocs:
            - path: docs/guide.md
              mode: review_or_update
          reason: inherited
rules:
  - id: root-only
    scope: workspace
    repo: workspace
    triggers:
      - path: AGENTS.md
        kind: doc
    requiredDocs:
      - path: .docpact/config.yaml
    reason: root
"#,
        )
        .expect("root config");

        fs::write(
            root.join(format!("sample-sdk/{CONFIG_FILE}")),
            r#"
version: 1
layout: repo
inherit:
  workspace_profile: default
overrides:
  rules:
    add:
      - id: local-extra
        scope: repo
        repo: sample-sdk
        triggers:
          - path: src/payments/**
            kind: code
        requiredDocs:
          - path: docs/payments.md
        reason: local
    replace:
      - id: inherited-rule
        scope: repo
        repo: sample-sdk
        triggers:
          - path: src/app/**
            kind: code
        requiredDocs:
          - path: docs/app.md
        reason: replaced
  coverage:
    mode: merge
    include:
      - tests/**
    exclude:
      - dist/**
  docInventory:
    mode: replace
    include:
      - README.md
  freshness:
    mode: replace
    warn_after_commits: 21
    warn_after_days: 34
    critical_after_days: 55
  routing:
    mode: merge
    intents:
      payments:
        paths:
          - src/payments/**
      shared:
        paths:
          - src/app/**
"#,
        )
        .expect("child config");

        let effective = load_effective_configs(&root, None).expect("effective configs");
        assert_eq!(effective.len(), 2);

        let child = effective
            .iter()
            .find(|entry| entry.base_dir == "sample-sdk")
            .expect("child config should exist");
        assert_eq!(
            child
                .inheritance
                .as_ref()
                .expect("inheritance should exist")
                .workspace_profile,
            "default"
        );
        assert_eq!(child.coverage.coverage.include, vec!["src/**", "tests/**"]);
        assert_eq!(child.coverage.coverage.exclude, vec!["dist/**"]);
        assert_eq!(
            child
                .routing
                .routing
                .intents
                .get("shared")
                .expect("shared intent")
                .paths,
            vec!["src/app/**"]
        );
        assert_eq!(
            child
                .routing
                .routing
                .intents
                .get("payments")
                .expect("payments intent")
                .paths,
            vec!["src/payments/**"]
        );
        assert_eq!(child.doc_inventory.doc_inventory.include, vec!["README.md"]);
        assert_eq!(child.freshness.freshness.warn_after_commits, 21);
        assert_eq!(child.rules.len(), 2);
        assert_eq!(child.rules[0].rule.id, "inherited-rule");
        assert_eq!(
            child.rules[0].provenance.origin_kind,
            RuleOriginKind::OverrideReplace
        );
        assert_eq!(child.rules[1].rule.id, "local-extra");
        assert_eq!(
            child.rules[1].provenance.origin_kind,
            RuleOriginKind::OverrideAdd
        );
        assert_eq!(
            child.rules[0].config_source,
            "sample-sdk/.docpact/config.yaml"
        );
        assert_eq!(child.rules[0].base_dir, "sample-sdk");
        assert_eq!(
            child
                .inheritance
                .as_ref()
                .expect("inheritance should exist")
                .routing_mode
                .as_deref(),
            Some("merge")
        );
    }

    #[test]
    fn workspace_child_without_inherit_remains_local_repo_config() {
        let root = temp_dir("docpact-config-local-child");
        fs::create_dir_all(root.join(DOC_ROOT_DIR)).expect("root doc dir");
        fs::create_dir_all(root.join(format!("sample-sdk/{DOC_ROOT_DIR}")))
            .expect("subrepo doc dir");

        fs::write(
            root.join(CONFIG_FILE),
            r#"
version: 1
layout: workspace
workspace:
  name: demo
rules: []
"#,
        )
        .expect("root config");

        fs::write(
            root.join(format!("sample-sdk/{CONFIG_FILE}")),
            r#"
version: 1
layout: repo
coverage:
  include:
    - src/**
rules:
  - id: repo-rule
    scope: repo
    repo: sample-sdk
    triggers:
      - path: src/**
        kind: code
    requiredDocs:
      - path: docs/guide.md
    reason: local
"#,
        )
        .expect("child config");

        let effective = load_effective_configs(&root, None).expect("effective configs");
        let child = effective
            .iter()
            .find(|entry| entry.base_dir == "sample-sdk")
            .expect("child config should exist");

        assert!(child.inheritance.is_none());
        assert_eq!(child.coverage.coverage.include, vec!["src/**"]);
        assert_eq!(child.rules[0].source, "sample-sdk/.docpact/config.yaml");
        assert_eq!(
            child.rules[0].provenance.origin_kind,
            RuleOriginKind::RepoLocal
        );
    }

    #[test]
    fn strict_validation_reports_invalid_inheritance_shapes() {
        let root = temp_dir("docpact-config-invalid-inherit");
        fs::create_dir_all(root.join(DOC_ROOT_DIR)).expect("root doc dir");
        fs::create_dir_all(root.join(format!("sample-sdk/{DOC_ROOT_DIR}")))
            .expect("subrepo doc dir");

        fs::write(
            root.join(CONFIG_FILE),
            r#"
version: 1
layout: workspace
workspace:
  name: demo
  profiles:
    default:
      rules:
        - id: inherited-rule
          scope: workspace
          repo: workspace
          triggers:
            - path: src/**
              kind: code
          requiredDocs:
            - path: docs/guide.md
          reason: inherited
rules: []
"#,
        )
        .expect("root config");

        fs::write(
            root.join(format!("sample-sdk/{CONFIG_FILE}")),
            r#"
version: 1
layout: repo
inherit:
  workspace_profile: default
coverage:
  include:
    - src/**
rules:
  - id: illegal-local
    scope: repo
    repo: sample-sdk
    triggers:
      - path: src/**
        kind: code
    requiredDocs:
      - path: docs/guide.md
    reason: local
overrides:
  freshness:
    mode: merge
"#,
        )
        .expect("child config");

        let problems = validate_config_graph(&root, None).expect("validation should work");
        let messages = problems
            .iter()
            .map(|problem| format!("{}: {}", problem.source, problem.message))
            .collect::<Vec<_>>();

        assert!(messages.iter().any(|message| {
            message
                .contains("must not define top-level `coverage`; move it into `overrides.coverage`")
        }));
        assert!(messages.iter().any(|message| {
            message.contains("must not define top-level `rules`; use `overrides.rules`")
        }));
        assert!(messages.iter().any(|message| {
            message.contains("overrides.freshness only supports `mode: replace`")
        }));
    }

    #[test]
    fn strict_validation_allows_scoped_duplicate_routing_intent_aliases() {
        let root = temp_dir("docpact-routing-duplicate-intents");
        fs::create_dir_all(root.join(DOC_ROOT_DIR)).expect("doc root dir");
        fs::create_dir_all(root.join(format!("a/{DOC_ROOT_DIR}"))).expect("repo a dir");
        fs::create_dir_all(root.join(format!("b/{DOC_ROOT_DIR}"))).expect("repo b dir");

        fs::write(
            root.join(CONFIG_FILE),
            r#"
version: 1
layout: workspace
rules: []
"#,
        )
        .expect("root config");
        fs::write(
            root.join(format!("a/{CONFIG_FILE}")),
            r#"
version: 1
layout: repo
routing:
  intents:
    payments:
      paths:
        - src/a/**
rules: []
"#,
        )
        .expect("repo a config");
        fs::write(
            root.join(format!("b/{CONFIG_FILE}")),
            r#"
version: 1
layout: repo
routing:
  intents:
    payments:
      paths:
        - src/b/**
rules: []
"#,
        )
        .expect("repo b config");

        let routing_configs = load_routing_configs(&root, None).expect("routing configs");
        let problems = validate_loaded_routing_configs(&routing_configs);

        assert!(problems.is_empty());
    }

    #[test]
    fn load_catalog_configs_normalizes_doc_pointers_and_repo_root() {
        let root = temp_dir("docpact-catalog-normalize");
        fs::create_dir_all(root.join(DOC_ROOT_DIR)).expect("doc root dir");

        fs::write(
            root.join(CONFIG_FILE),
            r#"
version: 1
layout: repo
catalog:
  repos:
    - id: sample-sdk
      path: .
      canonicalRepo: "  Biaoo/sample-sdk  "
      entryDoc: ./README.md
      branchPolicyDoc: docs/branch-policy.md
      workflowDocs:
        - ./docs/workflows/local-dev.md
        - docs/workflows/local-dev.md
        - docs/workflows/release.md
      integrationDocs:
        - docs/workflows/workspace-integration.md
        - ./docs/workflows/workspace-integration.md
rules: []
"#,
        )
        .expect("repo config");

        let loaded = load_catalog_configs(&root, None).expect("catalog configs");
        let catalog = &loaded[0].catalog;
        let repo = &catalog.repos[0];

        assert_eq!(repo.path, ".");
        assert_eq!(repo.canonical_repo.as_deref(), Some("Biaoo/sample-sdk"));
        assert_eq!(repo.entry_doc.as_deref(), Some("README.md"));
        assert_eq!(
            repo.workflow_docs,
            vec!["docs/workflows/local-dev.md", "docs/workflows/release.md"]
        );
        assert_eq!(
            repo.integration_docs,
            vec!["docs/workflows/workspace-integration.md"]
        );
    }

    #[test]
    fn strict_validation_reports_invalid_catalog_pointers_and_overlapping_roots() {
        let root = temp_dir("docpact-catalog-invalid");
        fs::create_dir_all(root.join(DOC_ROOT_DIR)).expect("doc root dir");
        fs::create_dir_all(root.join(format!("sample-sdk/{DOC_ROOT_DIR}"))).expect("child doc dir");

        fs::write(
            root.join(CONFIG_FILE),
            r#"
version: 1
layout: workspace
catalog:
  repos:
    - id: app
      path: apps
      entryDoc: docs/*.md
    - id: nested
      path: apps/web
rules: []
"#,
        )
        .expect("workspace config");

        fs::write(
            root.join(format!("sample-sdk/{CONFIG_FILE}")),
            r#"
version: 1
layout: repo
catalog:
  repos:
    - id: app
      path: services/api
rules: []
"#,
        )
        .expect("child config");

        let graph_problems = validate_config_graph(&root, None).expect("graph problems");
        assert!(graph_problems.iter().any(|problem| {
            problem
                .message
                .contains("catalog.repos[0].entryDoc must be an exact document path, not a glob")
        }));

        let catalog_configs = load_catalog_configs(&root, None).expect("catalog configs");
        let loaded_problems = validate_loaded_catalog_configs(&catalog_configs);
        let messages = loaded_problems
            .iter()
            .map(|problem| problem.message.as_str())
            .collect::<Vec<_>>();

        assert!(messages.iter().any(|message| {
            message.contains("catalog repo path `apps` overlaps with `apps/web`")
        }));
        assert!(messages.iter().any(|message| {
            message.contains("catalog repo path `apps/web` overlaps with `apps`")
        }));
    }

    #[test]
    fn load_ownership_configs_normalizes_patterns_and_repo_references() {
        let root = temp_dir("docpact-ownership-normalize");
        fs::create_dir_all(root.join(DOC_ROOT_DIR)).expect("doc root dir");

        fs::write(
            root.join(CONFIG_FILE),
            r#"
version: 1
layout: repo
ownership:
  domains:
    - id: " payments "
      paths:
        include:
          - ./src/payments/**
          - src/payments/**
        exclude:
          - ./src/payments/generated/**
      ownerRepo: " sample-sdk "
      nonOwnerRepos:
        - edge
        - edge
        - next
rules: []
"#,
        )
        .expect("repo config");

        let loaded = load_ownership_configs(&root, None).expect("ownership configs");
        let domain = &loaded[0].ownership.domains[0];

        assert_eq!(domain.id, "payments");
        assert_eq!(domain.paths.include, vec!["src/payments/**"]);
        assert_eq!(domain.paths.exclude, vec!["src/payments/generated/**"]);
        assert_eq!(domain.owner_repo, "sample-sdk");
        assert_eq!(domain.non_owner_repos, vec!["edge", "next"]);
    }

    #[test]
    fn strict_validation_reports_invalid_ownership_references_and_duplicate_ids() {
        let root = temp_dir("docpact-ownership-invalid");
        fs::create_dir_all(root.join(DOC_ROOT_DIR)).expect("doc root dir");
        fs::create_dir_all(root.join(format!("sample-sdk/{DOC_ROOT_DIR}"))).expect("child doc dir");

        fs::write(
            root.join(CONFIG_FILE),
            r#"
version: 1
layout: workspace
catalog:
  repos:
    - id: app
      path: apps/app
    - id: web
      path: apps/web
ownership:
  domains:
    - id: payments
      paths:
        include:
          - src/***
      ownerRepo: app
      nonOwnerRepos:
        - app
        - missing
    - id: checkout
      paths:
        include: []
      ownerRepo: missing
rules: []
"#,
        )
        .expect("workspace config");

        fs::write(
            root.join(format!("sample-sdk/{CONFIG_FILE}")),
            r#"
version: 1
layout: repo
ownership:
  domains:
    - id: payments
      paths:
        include:
          - src/web/**
      ownerRepo: web
rules: []
"#,
        )
        .expect("child config");

        let graph_problems = validate_config_graph(&root, None).expect("graph problems");
        assert!(graph_problems.iter().any(|problem| {
            problem
                .message
                .contains("ownership.domains[0].paths.include[0] contains malformed glob segment")
        }));
        assert!(graph_problems.iter().any(|problem| {
            problem.message.contains(
                "ownership domain `payments` must not list ownerRepo `app` inside nonOwnerRepos",
            )
        }));
        assert!(graph_problems.iter().any(|problem| {
            problem
                .message
                .contains("ownership.domains[1].paths.include must define at least one path")
        }));

        let ownership_configs = load_ownership_configs(&root, None).expect("ownership configs");
        let catalog_configs = load_catalog_configs(&root, None).expect("catalog configs");
        let loaded_problems =
            validate_loaded_ownership_configs(&ownership_configs, &catalog_configs);
        let messages = loaded_problems
            .iter()
            .map(|problem| problem.message.as_str())
            .collect::<Vec<_>>();

        assert!(messages.iter().any(|message| {
            message
                .contains("ownership domain `payments` references unknown nonOwnerRepo `missing`")
        }));
        assert!(messages.iter().any(|message| {
            message.contains("ownership domain `checkout` references unknown ownerRepo `missing`")
        }));
    }

    #[test]
    fn ownership_analysis_reports_conflicts_and_overlaps_and_selects_most_specific_domain() {
        let root = temp_dir("docpact-ownership-analysis");
        fs::create_dir_all(root.join(DOC_ROOT_DIR)).expect("doc root dir");
        fs::create_dir_all(root.join(format!("service/{DOC_ROOT_DIR}"))).expect("service doc dir");

        fs::write(
            root.join(CONFIG_FILE),
            r#"
version: 1
layout: workspace
ownership:
  domains:
    - id: broad-root
      paths:
        include:
          - service/src/**
      ownerRepo: app
    - id: payments-specific
      paths:
        include:
          - service/src/payments/**
      ownerRepo: app
    - id: conflicts
      paths:
        include:
          - service/src/conflict/**
      ownerRepo: edge
rules: []
"#,
        )
        .expect("root config");

        fs::write(
            root.join(format!("service/{CONFIG_FILE}")),
            r#"
version: 1
layout: repo
ownership:
  domains:
    - id: child-conflict
      paths:
        include:
          - src/conflict/**
      ownerRepo: web
rules: []
"#,
        )
        .expect("child config");

        let ownership_configs = load_ownership_configs(&root, None).expect("ownership configs");
        let analysis = analyze_ownership_paths(
            &[
                "service/src/payments/charge.ts".into(),
                "service/src/conflict/index.ts".into(),
            ],
            &ownership_configs,
        );

        assert_eq!(analysis.overlaps.len(), 1);
        assert_eq!(analysis.conflicts.len(), 1);
        assert_eq!(analysis.overlaps[0].selected_domain_id, "payments-specific");
        assert_eq!(
            analysis.conflicts[0].owner_repos,
            vec!["app", "edge", "web"]
        );

        let selected = analysis
            .paths
            .iter()
            .find(|entry| entry.path == "service/src/payments/charge.ts")
            .expect("payments path should exist");
        assert_eq!(selected.selected.domain_id, "payments-specific");

        let conflict_problems = validate_ownership_path_conflicts(&analysis);
        assert!(conflict_problems.iter().any(|problem| {
            problem
                .message
                .contains("tracked path `service/src/conflict/index.ts`")
        }));
        assert!(conflict_problems.iter().any(|problem| {
            problem
                .message
                .contains("conflicting ownerRepos are present: app, edge, web")
        }));
    }

    #[test]
    fn duplicate_effective_rule_ids_still_report_in_strict_validation() {
        let root = temp_dir("docpact-validate-duplicate-effective");
        fs::create_dir_all(root.join(DOC_ROOT_DIR)).expect("doc root dir");
        fs::create_dir_all(root.join(format!("subrepo/{DOC_ROOT_DIR}"))).expect("subrepo dir");

        fs::write(
            root.join(CONFIG_FILE),
            r#"
version: 1
layout: workspace
rules:
  - id: duplicate-rule
    scope: workspace
    repo: workspace
    triggers:
      - path: AGENTS.md
        kind: doc
    requiredDocs:
      - path: .docpact/config.yaml
    reason: root
"#,
        )
        .expect("root config");
        fs::write(
            root.join(format!("subrepo/{CONFIG_FILE}")),
            r#"
version: 1
layout: repo
rules:
  - id: duplicate-rule
    scope: repo
    repo: subrepo
    triggers:
      - path: src/**
        kind: code
    requiredDocs:
      - path: docs/guide.md
    reason: repo
"#,
        )
        .expect("subrepo config");

        let loaded = load_impact_files(&root, None).expect("rules should load");
        let problems = validate_loaded_rules(&loaded);
        assert!(problems.iter().any(|problem| {
            problem
                .message
                .contains("duplicate rule id `duplicate-rule`")
        }));
    }

    #[test]
    fn resolve_rule_path_uses_base_dir() {
        assert_eq!(
            resolve_rule_path("subrepo", ".docpact/config.yaml"),
            "subrepo/.docpact/config.yaml"
        );
    }

    #[test]
    fn root_dir_from_option_preserves_explicit_path() {
        let root = temp_dir("docpact-config-root-dir");
        let resolved = root_dir_from_option(Some(root.as_path())).expect("root dir");
        assert_eq!(resolved, root);
    }
}
