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

use miette::Result;
use serde::Serialize;

use crate::AppExit;
use crate::cli::{DoctorArgs, DoctorOutputFormat};
use crate::config::{
    CONFIG_FILE, ConfigBlockSourceKind, EffectiveConfig, ImpactLayout, analyze_ownership_paths,
    detect_impact_layout, load_catalog_configs, load_effective_configs, load_ownership_configs,
    normalize_path, path_relative_to, resolve_rule_path, root_dir_from_option,
};
use crate::git::get_tracked_paths;
use crate::reporters::OutputWarning;

pub const DOCTOR_SCHEMA_VERSION: &str = "docpact.doctor.v1";

const CODE_MISSING_CONFIG: &str = "missing-config";
const CODE_CONFIG_LOAD_FAILED: &str = "config-load-failed";
const CODE_EMPTY_RULE_GRAPH: &str = "empty-rule-graph";
const CODE_MISSING_COVERAGE_SCOPE: &str = "missing-coverage-scope";
const CODE_MISSING_GOVERNED_DOCS: &str = "missing-governed-docs";
const CODE_MISSING_DOC_INVENTORY: &str = "missing-doc-inventory";
const CODE_MISSING_FRESHNESS_CONFIG: &str = "missing-freshness-config";
const CODE_OWNERSHIP_OVERLAP: &str = "ownership-overlap";
const CODE_OWNERSHIP_CONFLICT: &str = "ownership-conflict";
const CODE_OWNERSHIP_ANALYSIS_UNAVAILABLE: &str = "ownership-analysis-unavailable";

#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
pub struct DoctorReport {
    pub schema_version: String,
    pub tool_name: String,
    pub tool_version: String,
    pub command: String,
    pub warnings: Vec<OutputWarning>,
    pub summary: DoctorSummary,
    pub configs: Vec<DoctorConfig>,
    pub findings: Vec<DoctorFinding>,
}

#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
pub struct DoctorSummary {
    pub config_present: bool,
    pub layout: String,
    pub effective_config_count: usize,
    pub inherited_config_count: usize,
    pub rule_count: usize,
    pub catalog_repo_count: usize,
    pub ownership_domain_count: usize,
    pub ownership_overlap_count: usize,
    pub ownership_conflict_count: usize,
    pub coverage_configured: bool,
    pub routing_configured: bool,
    pub doc_inventory_configured: bool,
    pub freshness_configured: bool,
    pub routing_intent_count: usize,
    pub governed_doc_count: usize,
}

#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
pub struct DoctorConfig {
    pub source: String,
    pub base_dir: String,
    pub rule_count: usize,
    pub catalog_repo_count: usize,
    pub ownership_domain_count: usize,
    pub governed_doc_count: usize,
    pub inheritance_enabled: bool,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub workspace_profile: Option<String>,
    pub coverage_resolution: String,
    pub routing_resolution: String,
    pub doc_inventory_resolution: String,
    pub freshness_resolution: String,
    pub routing_intent_count: usize,
    pub override_add_count: usize,
    pub override_replace_count: usize,
    pub override_disable_count: usize,
}

#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
pub struct DoctorFinding {
    pub code: String,
    pub severity: String,
    pub message: String,
    pub source: String,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
enum DoctorSeverity {
    Warn,
    Error,
}

impl DoctorSeverity {
    fn as_str(self) -> &'static str {
        match self {
            Self::Warn => "warn",
            Self::Error => "error",
        }
    }
}

pub fn run(args: DoctorArgs) -> Result<AppExit> {
    let report = execute(&args)?;
    emit_report(&report, args.format);
    Ok(AppExit::Success)
}

pub fn execute(args: &DoctorArgs) -> Result<DoctorReport> {
    let root_dir = root_dir_from_option(args.root.as_deref())?;
    let config_path = resolve_config_path(&root_dir, args.config.as_deref());
    let config_source = display_config_source(&root_dir, &config_path);

    if !config_path.exists() {
        return Ok(report_with_findings(
            DoctorSummary {
                config_present: false,
                layout: "none".into(),
                effective_config_count: 0,
                inherited_config_count: 0,
                rule_count: 0,
                catalog_repo_count: 0,
                ownership_domain_count: 0,
                ownership_overlap_count: 0,
                ownership_conflict_count: 0,
                coverage_configured: false,
                routing_configured: false,
                doc_inventory_configured: false,
                freshness_configured: false,
                routing_intent_count: 0,
                governed_doc_count: 0,
            },
            Vec::new(),
            vec![DoctorFinding {
                code: CODE_MISSING_CONFIG.into(),
                severity: DoctorSeverity::Error.as_str().into(),
                message: format!(
                    "No docpact config was found at `{}`. Create a config before onboarding the repository.",
                    config_source
                ),
                source: config_source,
            }],
        ));
    }

    let layout = match detect_impact_layout(&root_dir, args.config.as_deref()) {
        Ok(layout) => layout,
        Err(error) => {
            let message = error.to_string();
            let source = extract_config_source(&message).unwrap_or_else(|| config_source.clone());
            return Ok(report_with_findings(
                DoctorSummary {
                    config_present: true,
                    layout: "unknown".into(),
                    effective_config_count: 0,
                    inherited_config_count: 0,
                    rule_count: 0,
                    catalog_repo_count: 0,
                    ownership_domain_count: 0,
                    ownership_overlap_count: 0,
                    ownership_conflict_count: 0,
                    coverage_configured: false,
                    routing_configured: false,
                    doc_inventory_configured: false,
                    freshness_configured: false,
                    routing_intent_count: 0,
                    governed_doc_count: 0,
                },
                Vec::new(),
                vec![DoctorFinding {
                    code: CODE_CONFIG_LOAD_FAILED.into(),
                    severity: DoctorSeverity::Error.as_str().into(),
                    message,
                    source,
                }],
            ));
        }
    };

    let effective_configs = match load_effective_configs(&root_dir, args.config.as_deref()) {
        Ok(configs) => configs,
        Err(error) => {
            let message = error.to_string();
            let source = extract_config_source(&message).unwrap_or_else(|| config_source.clone());
            return Ok(report_with_findings(
                DoctorSummary {
                    config_present: true,
                    layout: layout_label(layout).into(),
                    effective_config_count: 0,
                    inherited_config_count: 0,
                    rule_count: 0,
                    catalog_repo_count: 0,
                    ownership_domain_count: 0,
                    ownership_overlap_count: 0,
                    ownership_conflict_count: 0,
                    coverage_configured: false,
                    routing_configured: false,
                    doc_inventory_configured: false,
                    freshness_configured: false,
                    routing_intent_count: 0,
                    governed_doc_count: 0,
                },
                Vec::new(),
                vec![DoctorFinding {
                    code: CODE_CONFIG_LOAD_FAILED.into(),
                    severity: DoctorSeverity::Error.as_str().into(),
                    message,
                    source,
                }],
            ));
        }
    };

    let catalog_configs = load_catalog_configs(&root_dir, args.config.as_deref())?;
    let ownership_configs = load_ownership_configs(&root_dir, args.config.as_deref())?;
    let configs = build_doctor_configs(&effective_configs, &catalog_configs, &ownership_configs);
    let rule_count = effective_configs
        .iter()
        .map(|config| config.rules.len())
        .sum();
    let catalog_repo_count = catalog_configs
        .iter()
        .map(|config| config.catalog.repos.len())
        .sum();
    let ownership_domain_count = ownership_configs
        .iter()
        .map(|config| config.ownership.domains.len())
        .sum();
    let governed_doc_count = effective_configs
        .iter()
        .flat_map(|config| {
            config.rules.iter().flat_map(|loaded| {
                loaded
                    .rule
                    .required_docs
                    .iter()
                    .map(|doc| resolve_rule_path(&loaded.base_dir, &doc.path))
            })
        })
        .collect::<BTreeSet<_>>()
        .len();
    let coverage_configured = effective_configs.iter().any(|config| {
        !config.coverage.coverage.include.is_empty() || !config.coverage.coverage.exclude.is_empty()
    });
    let doc_inventory_configured = effective_configs.iter().any(|config| {
        !config.doc_inventory.doc_inventory.include.is_empty()
            || !config.doc_inventory.doc_inventory.exclude.is_empty()
    });
    let routing_configured = effective_configs
        .iter()
        .any(|config| !config.routing.routing.intents.is_empty());
    let freshness_configured = effective_configs
        .iter()
        .any(|config| config.freshness.resolution.origin_kind != ConfigBlockSourceKind::Default);
    let routing_intent_count = effective_configs
        .iter()
        .map(|config| config.routing.routing.intents.len())
        .sum();
    let inherited_config_count = effective_configs
        .iter()
        .filter(|config| config.inheritance.is_some())
        .count();
    let ownership_analysis = if ownership_domain_count > 0 {
        match get_tracked_paths(&root_dir) {
            Ok(tracked_paths) => Some(analyze_ownership_paths(&tracked_paths, &ownership_configs)),
            Err(_) => None,
        }
    } else {
        None
    };
    let ownership_overlap_count = ownership_analysis
        .as_ref()
        .map(|analysis| analysis.overlaps.len())
        .unwrap_or(0);
    let ownership_conflict_count = ownership_analysis
        .as_ref()
        .map(|analysis| analysis.conflicts.len())
        .unwrap_or(0);

    let summary = DoctorSummary {
        config_present: true,
        layout: layout_label(layout).into(),
        effective_config_count: effective_configs.len(),
        inherited_config_count,
        rule_count,
        catalog_repo_count,
        ownership_domain_count,
        ownership_overlap_count,
        ownership_conflict_count,
        coverage_configured,
        routing_configured,
        doc_inventory_configured,
        freshness_configured,
        routing_intent_count,
        governed_doc_count,
    };

    let mut findings = Vec::new();
    if summary.rule_count == 0 {
        findings.push(DoctorFinding {
            code: CODE_EMPTY_RULE_GRAPH.into(),
            severity: DoctorSeverity::Error.as_str().into(),
            message:
                "The loaded config graph contains zero rules. Add at least one rule before relying on docpact enforcement."
                    .into(),
            source: config_source.clone(),
        });
    } else {
        if summary.governed_doc_count == 0 {
            findings.push(DoctorFinding {
                code: CODE_MISSING_GOVERNED_DOCS.into(),
                severity: DoctorSeverity::Error.as_str().into(),
                message:
                    "The loaded rules do not resolve to any governed required docs. Add `requiredDocs` targets before using lint as a governance signal."
                        .into(),
                source: config_source.clone(),
            });
        }

        if !summary.coverage_configured {
            findings.push(DoctorFinding {
                code: CODE_MISSING_COVERAGE_SCOPE.into(),
                severity: DoctorSeverity::Warn.as_str().into(),
                message:
                    "No coverage scope is configured; diff coverage defaults to all changed paths. Configure `coverage.include` or `coverage.exclude` if the intended governance scope is narrower."
                        .into(),
                source: config_source.clone(),
            });
        }

        if !summary.doc_inventory_configured {
            findings.push(DoctorFinding {
                code: CODE_MISSING_DOC_INVENTORY.into(),
                severity: DoctorSeverity::Warn.as_str().into(),
                message:
                    "No explicit doc inventory scope is configured; repository coverage audit will infer inventory from all tracked Markdown/YAML docs."
                        .into(),
                source: config_source.clone(),
            });
        }

        if !summary.freshness_configured {
            findings.push(DoctorFinding {
                code: CODE_MISSING_FRESHNESS_CONFIG.into(),
                severity: DoctorSeverity::Warn.as_str().into(),
                message:
                    "No explicit freshness config is present; repository freshness audit falls back to default thresholds."
                        .into(),
                source: config_source.clone(),
            });
        }

        if summary.ownership_domain_count > 0 {
            match ownership_analysis {
                Some(ref analysis) => {
                    if !analysis.conflicts.is_empty() {
                        findings.push(DoctorFinding {
                            code: CODE_OWNERSHIP_CONFLICT.into(),
                            severity: DoctorSeverity::Error.as_str().into(),
                            message: format!(
                                "{} tracked path(s) match ownership domains with conflicting ownerRepo values. Examples: {}",
                                analysis.conflicts.len(),
                                summarize_paths(
                                    analysis
                                        .conflicts
                                        .iter()
                                        .map(|conflict| conflict.path.as_str())
                                        .collect::<Vec<_>>()
                                )
                            ),
                            source: config_source.clone(),
                        });
                    }

                    if !analysis.overlaps.is_empty() {
                        findings.push(DoctorFinding {
                            code: CODE_OWNERSHIP_OVERLAP.into(),
                            severity: DoctorSeverity::Warn.as_str().into(),
                            message: format!(
                                "{} tracked path(s) match multiple ownership domains with the same ownerRepo. Examples: {}",
                                analysis.overlaps.len(),
                                summarize_paths(
                                    analysis
                                        .overlaps
                                        .iter()
                                        .map(|overlap| overlap.path.as_str())
                                        .collect::<Vec<_>>()
                                )
                            ),
                            source: config_source.clone(),
                        });
                    }
                }
                None => findings.push(DoctorFinding {
                    code: CODE_OWNERSHIP_ANALYSIS_UNAVAILABLE.into(),
                    severity: DoctorSeverity::Warn.as_str().into(),
                    message:
                        "Ownership analysis could not read tracked files from git, so overlap/conflict checks were skipped."
                            .into(),
                    source: config_source.clone(),
                }),
            }
        }
    }

    findings.sort_by(|left, right| {
        (&left.severity, &left.code, &left.source, &left.message).cmp(&(
            &right.severity,
            &right.code,
            &right.source,
            &right.message,
        ))
    });

    Ok(report_with_findings(summary, configs, findings))
}

fn report_with_findings(
    summary: DoctorSummary,
    configs: Vec<DoctorConfig>,
    findings: Vec<DoctorFinding>,
) -> DoctorReport {
    DoctorReport {
        schema_version: DOCTOR_SCHEMA_VERSION.into(),
        tool_name: env!("CARGO_PKG_NAME").into(),
        tool_version: env!("CARGO_PKG_VERSION").into(),
        command: "doctor".into(),
        warnings: Vec::new(),
        summary,
        configs,
        findings,
    }
}

fn emit_report(report: &DoctorReport, format: DoctorOutputFormat) {
    match format {
        DoctorOutputFormat::Text => emit_text_report(report),
        DoctorOutputFormat::Json => println!(
            "{}",
            serde_json::to_string_pretty(report).expect("doctor report should serialize")
        ),
    }
}

fn emit_text_report(report: &DoctorReport) {
    let status = if report.findings.is_empty() {
        "pass"
    } else {
        "attention required"
    };
    println!("Docpact doctor: {status}.");
    println!(
        "Summary: config_present={}, layout={}, effective_configs={}, inherited_configs={}, rule_count={}, catalog_repos={}, ownership_domains={}, ownership_overlaps={}, ownership_conflicts={}, coverage_configured={}, routing_configured={}, doc_inventory_configured={}, freshness_configured={}, routing_intents={}, governed_doc_count={}",
        report.summary.config_present,
        report.summary.layout,
        report.summary.effective_config_count,
        report.summary.inherited_config_count,
        report.summary.rule_count,
        report.summary.catalog_repo_count,
        report.summary.ownership_domain_count,
        report.summary.ownership_overlap_count,
        report.summary.ownership_conflict_count,
        report.summary.coverage_configured,
        report.summary.routing_configured,
        report.summary.doc_inventory_configured,
        report.summary.freshness_configured,
        report.summary.routing_intent_count,
        report.summary.governed_doc_count,
    );
    println!("Configs:");
    if report.configs.is_empty() {
        println!("- none");
    } else {
        for config in &report.configs {
            println!(
                "- {}: {} rule(s), {} governed doc(s), routing intents={}, inherited={}, profile={}, base_dir={}",
                config.source,
                config.rule_count,
                config.governed_doc_count,
                config.routing_intent_count,
                config.inheritance_enabled,
                config
                    .workspace_profile
                    .clone()
                    .unwrap_or_else(|| "-".into()),
                if config.base_dir.is_empty() {
                    ".".to_string()
                } else {
                    config.base_dir.clone()
                },
            );
            println!(
                "  coverage={}, routing={}, doc_inventory={}, freshness={}, overrides(add={}, replace={}, disable={})",
                config.coverage_resolution,
                config.routing_resolution,
                config.doc_inventory_resolution,
                config.freshness_resolution,
                config.override_add_count,
                config.override_replace_count,
                config.override_disable_count,
            );
        }
    }
    println!("Findings:");
    if report.findings.is_empty() {
        println!("- none");
        println!(
            "Next: run `docpact lint --root . <diff-source>` or `docpact route --paths <path>`."
        );
        return;
    }

    for finding in &report.findings {
        println!(
            "- [{}] {} {}: {}",
            finding.severity, finding.code, finding.source, finding.message
        );
    }
    println!("Next: fix the findings above, then rerun `docpact doctor --root .`.");
}

fn resolve_config_path(root_dir: &Path, config_override: Option<&Path>) -> PathBuf {
    match config_override {
        Some(path) => path.to_path_buf(),
        None => root_dir.join(CONFIG_FILE),
    }
}

fn display_config_source(root_dir: &Path, path: &Path) -> String {
    if path.is_absolute() {
        path_relative_to(root_dir, path)
    } else {
        normalize_path(&path.to_string_lossy())
    }
}

fn layout_label(layout: ImpactLayout) -> &'static str {
    match layout {
        ImpactLayout::Repo => "repo",
        ImpactLayout::Workspace => "workspace",
        ImpactLayout::None => "none",
    }
}

fn extract_config_source(message: &str) -> Option<String> {
    for marker in [
        " is not a valid docpact config file.",
        " is not valid YAML for docpact.",
    ] {
        if let Some(index) = message.find(marker) {
            return Some(message[..index].to_string());
        }
    }
    None
}

fn summarize_paths(paths: Vec<&str>) -> String {
    let examples = paths.into_iter().take(3).collect::<Vec<_>>();
    if examples.is_empty() {
        "-".into()
    } else {
        examples.join(", ")
    }
}

fn build_doctor_configs(
    effective_configs: &[EffectiveConfig],
    catalog_configs: &[crate::config::LoadedCatalogConfig],
    ownership_configs: &[crate::config::LoadedOwnershipConfig],
) -> Vec<DoctorConfig> {
    let catalog_counts = catalog_configs
        .iter()
        .map(|config| (config.source.as_str(), config.catalog.repos.len()))
        .collect::<std::collections::BTreeMap<_, _>>();
    let ownership_counts = ownership_configs
        .iter()
        .map(|config| (config.source.as_str(), config.ownership.domains.len()))
        .collect::<std::collections::BTreeMap<_, _>>();

    effective_configs
        .iter()
        .map(|config| {
            let governed_doc_count = config
                .rules
                .iter()
                .flat_map(|loaded| {
                    loaded
                        .rule
                        .required_docs
                        .iter()
                        .map(|doc| resolve_rule_path(&loaded.base_dir, &doc.path))
                })
                .collect::<BTreeSet<_>>()
                .len();

            DoctorConfig {
                source: config.source.clone(),
                base_dir: config.base_dir.clone(),
                rule_count: config.rules.len(),
                catalog_repo_count: *catalog_counts.get(config.source.as_str()).unwrap_or(&0),
                ownership_domain_count: *ownership_counts.get(config.source.as_str()).unwrap_or(&0),
                governed_doc_count,
                inheritance_enabled: config.inheritance.is_some(),
                workspace_profile: config
                    .inheritance
                    .as_ref()
                    .map(|inheritance| inheritance.workspace_profile.clone()),
                coverage_resolution: config.coverage.resolution.origin_kind.as_str().into(),
                routing_resolution: config.routing.resolution.origin_kind.as_str().into(),
                doc_inventory_resolution: config
                    .doc_inventory
                    .resolution
                    .origin_kind
                    .as_str()
                    .into(),
                freshness_resolution: config.freshness.resolution.origin_kind.as_str().into(),
                routing_intent_count: config.routing.routing.intents.len(),
                override_add_count: config
                    .inheritance
                    .as_ref()
                    .map(|inheritance| inheritance.add_count)
                    .unwrap_or(0),
                override_replace_count: config
                    .inheritance
                    .as_ref()
                    .map(|inheritance| inheritance.replace_count)
                    .unwrap_or(0),
                override_disable_count: config
                    .inheritance
                    .as_ref()
                    .map(|inheritance| inheritance.disable_count)
                    .unwrap_or(0),
            }
        })
        .collect()
}

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

    use super::{
        CODE_CONFIG_LOAD_FAILED, CODE_EMPTY_RULE_GRAPH, CODE_MISSING_CONFIG,
        CODE_MISSING_COVERAGE_SCOPE, CODE_MISSING_DOC_INVENTORY, CODE_MISSING_FRESHNESS_CONFIG,
        CODE_MISSING_GOVERNED_DOCS, CODE_OWNERSHIP_OVERLAP, DOCTOR_SCHEMA_VERSION, execute,
    };
    use crate::cli::{
        DoctorArgs, DoctorOutputFormat, ValidateConfigArgs, ValidateConfigOutputFormat,
    };
    use crate::config::CONFIG_FILE;
    use crate::validate_config;

    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
    }

    fn base_args(root: &std::path::Path) -> DoctorArgs {
        DoctorArgs {
            root: Some(root.to_path_buf()),
            config: None,
            format: DoctorOutputFormat::Json,
        }
    }

    fn git(root: &std::path::Path, args: &[&str]) -> String {
        let output = Command::new("git")
            .args(args)
            .current_dir(root)
            .output()
            .expect("git command should run");
        assert!(
            output.status.success(),
            "git {:?} failed: {}",
            args,
            String::from_utf8_lossy(&output.stderr)
        );
        String::from_utf8(output.stdout)
            .expect("git stdout should be utf-8")
            .trim()
            .to_string()
    }

    fn init_git_repo(root: &std::path::Path) {
        fs::create_dir_all(root).expect("repo root should exist");
        git(root, &["init"]);
        git(root, &["config", "user.name", "Docpact Tests"]);
        git(root, &["config", "user.email", "docpact@example.com"]);
    }

    #[test]
    fn doctor_reports_missing_config() {
        let root = temp_dir("docpact-doctor-missing");

        let report = execute(&base_args(&root)).expect("doctor should execute");

        assert_eq!(report.schema_version, DOCTOR_SCHEMA_VERSION);
        assert_eq!(report.command, "doctor");
        assert!(report.warnings.is_empty());
        assert!(!report.summary.config_present);
        assert_eq!(report.summary.layout, "none");
        assert_eq!(report.findings.len(), 1);
        assert_eq!(report.findings[0].code, CODE_MISSING_CONFIG);
    }

    #[test]
    fn doctor_reports_empty_rule_graph() {
        let root = temp_dir("docpact-doctor-empty");
        fs::create_dir_all(root.join(".docpact")).expect("doc root should exist");
        fs::write(
            root.join(CONFIG_FILE),
            r#"version: 1
layout: repo
rules: []
"#,
        )
        .expect("config should be written");

        let report = execute(&base_args(&root)).expect("doctor should execute");

        assert_eq!(report.summary.config_present, true);
        assert_eq!(report.summary.rule_count, 0);
        assert_eq!(report.findings[0].code, CODE_EMPTY_RULE_GRAPH);
    }

    #[test]
    fn doctor_and_strict_validate_agree_on_nested_repo_local_scopes() {
        let root = temp_dir("docpact-doctor-workspace-scopes");
        init_git_repo(&root);
        fs::create_dir_all(root.join(".docpact")).expect("doc root should exist");
        fs::create_dir_all(root.join("repo-a/.docpact")).expect("repo a config dir");
        fs::create_dir_all(root.join("repo-b/.docpact")).expect("repo b config dir");
        fs::create_dir_all(root.join("repo-a/src")).expect("repo a src");
        fs::create_dir_all(root.join("repo-b/src")).expect("repo b src");
        fs::create_dir_all(root.join("docs")).expect("docs dir");

        fs::write(
            root.join(CONFIG_FILE),
            r#"
version: 1
layout: workspace
coverage:
  include:
    - repo-a/**
    - repo-b/**
docInventory:
  include:
    - docs/**
freshness:
  warn_after_commits: 20
  warn_after_days: 30
  critical_after_days: 60
catalog:
  repos:
    - id: repo-a
      path: repo-a
    - id: repo-b
      path: repo-b
routing:
  intents:
    workspace-integration:
      paths:
        - repo-a/src/**
rules:
  - id: workspace-docs
    scope: workspace
    repo: workspace
    triggers:
      - path: repo-a/src/**
        kind: code
    requiredDocs:
      - path: docs/workspace.md
        mode: review_or_update
    reason: Workspace changes require workspace docs.
"#,
        )
        .expect("root config should be written");

        for repo in ["repo-a", "repo-b"] {
            fs::write(
                root.join(format!("{repo}/.docpact/config.yaml")),
                format!(
                    r#"
version: 1
layout: repo
catalog:
  repos:
    - id: {repo}
      path: .
ownership:
  domains:
    - id: repo-governance-and-docs
      paths:
        include:
          - src/**
      ownerRepo: {repo}
routing:
  intents:
    repo-docs:
      paths:
        - src/**
rules: []
"#
                ),
            )
            .expect("child config should be written");
            fs::write(
                root.join(format!("{repo}/src/index.ts")),
                "export const x = 1;\n",
            )
            .expect("tracked source should be written");
        }
        fs::write(root.join("docs/workspace.md"), "# Workspace\n").expect("doc should be written");
        git(&root, &["add", "."]);

        let doctor_report = execute(&base_args(&root)).expect("doctor should execute");
        let validate_report = validate_config::execute(&ValidateConfigArgs {
            root: Some(root),
            config: None,
            strict: true,
            format: ValidateConfigOutputFormat::Json,
        })
        .expect("validate-config should execute");

        assert!(doctor_report.findings.is_empty());
        assert_eq!(doctor_report.summary.effective_config_count, 3);
        assert_eq!(doctor_report.summary.ownership_conflict_count, 0);
        assert_eq!(validate_report.summary.status, "ok");
        assert_eq!(validate_report.summary.problem_count, 0);
    }

    #[test]
    fn doctor_reports_governance_gaps_without_repeating_strict_validation() {
        let root = temp_dir("docpact-doctor-gaps");
        fs::create_dir_all(root.join(".docpact")).expect("doc root should exist");
        fs::write(
            root.join(CONFIG_FILE),
            r#"version: 1
layout: repo
rules:
  - id: no-governed-docs
    scope: repo
    repo: sample
    triggers:
      - path: src/**
        kind: code
    requiredDocs: []
    reason: sample
"#,
        )
        .expect("config should be written");

        let report = execute(&base_args(&root)).expect("doctor should execute");

        assert_eq!(report.summary.rule_count, 1);
        let codes = report
            .findings
            .iter()
            .map(|finding| finding.code.as_str())
            .collect::<Vec<_>>();
        assert!(codes.contains(&CODE_MISSING_GOVERNED_DOCS));
        assert!(codes.contains(&CODE_MISSING_COVERAGE_SCOPE));
        assert!(codes.contains(&CODE_MISSING_DOC_INVENTORY));
        assert!(codes.contains(&CODE_MISSING_FRESHNESS_CONFIG));
        assert!(!codes.contains(&"invalid-config"));
    }

    #[test]
    fn doctor_reports_explicit_config_load_failures() {
        let root = temp_dir("docpact-doctor-invalid");
        fs::create_dir_all(root.join(".docpact")).expect("doc root should exist");
        fs::write(root.join(CONFIG_FILE), "layout: [").expect("config should be written");

        let report = execute(&base_args(&root)).expect("doctor should execute");

        assert_eq!(report.summary.layout, "unknown");
        assert_eq!(report.findings.len(), 1);
        assert_eq!(report.findings[0].code, CODE_CONFIG_LOAD_FAILED);
    }

    #[test]
    fn doctor_marks_explicit_scopes_and_thresholds_as_configured() {
        let root = temp_dir("docpact-doctor-configured");
        fs::create_dir_all(root.join(".docpact")).expect("doc root should exist");
        fs::write(
            root.join(CONFIG_FILE),
            r#"version: 1
layout: repo
coverage:
  include:
    - src/**
docInventory:
  include:
    - docs/**
freshness:
  warn_after_commits: 10
  warn_after_days: 30
  critical_after_days: 60
routing:
  intents:
    api:
      paths:
        - src/**
rules:
  - id: api-docs
    scope: repo
    repo: sample
    triggers:
      - path: src/**
        kind: code
    requiredDocs:
      - path: docs/api.md
    reason: sample
"#,
        )
        .expect("config should be written");

        let report = execute(&base_args(&root)).expect("doctor should execute");

        assert!(report.summary.coverage_configured);
        assert!(report.summary.routing_configured);
        assert!(report.summary.doc_inventory_configured);
        assert!(report.summary.freshness_configured);
        assert_eq!(report.summary.routing_intent_count, 1);
        assert_eq!(report.summary.governed_doc_count, 1);
        assert!(report.findings.is_empty());
    }

    #[test]
    fn doctor_reports_inheritance_and_override_details() {
        let root = temp_dir("docpact-doctor-inheritance");
        fs::create_dir_all(root.join(".docpact")).expect("doc root should exist");
        fs::create_dir_all(root.join("service/.docpact")).expect("service doc root should exist");
        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: 30
        critical_after_days: 60
      routing:
        intents:
          service:
            paths:
              - src/**
      rules:
        - id: inherited-rule
          scope: workspace
          repo: workspace
          triggers:
            - path: src/**
              kind: code
          requiredDocs:
            - path: docs/guide.md
          reason: inherited
        - id: inherited-disable
          scope: workspace
          repo: workspace
          triggers:
            - path: src/legacy/**
              kind: code
          requiredDocs:
            - path: docs/legacy.md
          reason: disable me
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("service/.docpact/config.yaml"),
            r#"version: 1
layout: repo
inherit:
  workspace_profile: default
overrides:
  rules:
    add:
      - id: local-extra
        scope: repo
        repo: service
        triggers:
          - path: src/payments/**
            kind: code
        requiredDocs:
          - path: docs/payments.md
        reason: local
    replace:
      - id: inherited-rule
        scope: repo
        repo: service
        triggers:
          - path: src/app/**
            kind: code
        requiredDocs:
          - path: docs/app.md
        reason: replaced
    disable:
      - id: inherited-disable
        reason: not applicable
  coverage:
    mode: merge
    include:
      - tests/**
  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/**
"#,
        )
        .expect("service config");

        let report = execute(&base_args(&root)).expect("doctor should execute");

        assert_eq!(report.summary.layout, "workspace");
        assert_eq!(report.summary.effective_config_count, 2);
        assert_eq!(report.summary.inherited_config_count, 1);

        let service = report
            .configs
            .iter()
            .find(|config| config.base_dir == "service")
            .expect("service config should exist");
        assert!(service.inheritance_enabled);
        assert_eq!(service.workspace_profile.as_deref(), Some("default"));
        assert_eq!(service.override_add_count, 1);
        assert_eq!(service.override_replace_count, 1);
        assert_eq!(service.override_disable_count, 1);
        assert_eq!(service.coverage_resolution, "override-merge");
        assert_eq!(service.routing_resolution, "override-merge");
        assert_eq!(service.doc_inventory_resolution, "override-replace");
        assert_eq!(service.freshness_resolution, "override-replace");
        assert_eq!(service.routing_intent_count, 2);
    }

    #[test]
    fn doctor_surfaces_same_owner_ownership_overlap_as_warning() {
        let root = temp_dir("docpact-doctor-ownership-overlap");
        init_git_repo(&root);
        fs::create_dir_all(root.join(".docpact")).expect("doc root should exist");
        fs::create_dir_all(root.join("src/payments")).expect("src dir should exist");

        fs::write(
            root.join(CONFIG_FILE),
            r#"version: 1
layout: repo
catalog:
  repos:
    - id: sample
      path: .
ownership:
  domains:
    - id: broad
      paths:
        include:
          - src/**
      ownerRepo: sample
    - id: payments
      paths:
        include:
          - src/payments/**
      ownerRepo: sample
rules:
  - id: api-docs
    scope: repo
    repo: sample
    triggers:
      - path: src/**
        kind: code
    requiredDocs:
      - path: README.md
    reason: sample
"#,
        )
        .expect("config should be written");
        fs::write(root.join("src/payments/charge.ts"), "export const x = 1;\n")
            .expect("tracked file should be written");
        git(&root, &["add", "."]);
        git(&root, &["commit", "-m", "Add tracked overlap sample"]);

        let report = execute(&base_args(&root)).expect("doctor should execute");

        assert_eq!(report.summary.ownership_domain_count, 2);
        assert_eq!(report.summary.ownership_overlap_count, 1);
        assert_eq!(report.summary.ownership_conflict_count, 0);
        assert!(report.findings.iter().any(|finding| {
            finding.code == CODE_OWNERSHIP_OVERLAP && finding.severity == "warn"
        }));
    }
}
