//! Claim coverage metrics engine.
//!
//! Computes per-module coverage: how many observations are claimed,
//! how many claims are verified, what's uncovered. Uses `verify_claims()`
//! as the source of truth for claim-observation matching.

use std::collections::BTreeMap;

use serde::Serialize;

use crate::types::authored_claim::AuthoredClaim;
use crate::types::Observation;
use crate::verify::{tail_path, verify_claims, AuditVerdict, VerifyReport};

/// Per-module coverage metrics.
#[derive(Debug, Clone, Serialize)]
pub struct ModuleCoverage {
    /// Module path (e.g., "wallet/atomics", "tls").
    pub module_path: String,
    /// Files belonging to this module.
    pub files: Vec<String>,
    /// Total observations found by extractors in this module.
    pub observation_count: usize,
    /// Active authored claims covering this module.
    pub claim_count: usize,
    /// Observations matched by at least one claim.
    pub claimed_observations: usize,
    /// Observations with no covering claim.
    pub unclaimed_observations: usize,
    /// Claims with no matching observation (MISSING verdicts).
    pub missing_claims: usize,
    /// Claim density: claim_count / observation_count (0.0 if no observations).
    pub density: f32,
}

/// Full coverage report for a project.
#[derive(Debug, Clone, Serialize)]
pub struct CoverageReport {
    /// Project name.
    pub project: String,
    /// Per-module metrics, sorted by module path.
    pub modules: Vec<ModuleCoverage>,
    /// Aggregate summary.
    pub summary: CoverageSummary,
}

/// Aggregate coverage summary.
#[derive(Debug, Clone, Serialize)]
pub struct CoverageSummary {
    /// Total observations across all modules.
    pub total_observations: usize,
    /// Total active claims.
    pub total_claims: usize,
    /// Percentage of observations covered by claims.
    pub claimed_percentage: f32,
    /// Count of observations with no covering claim.
    pub unclaimed_count: usize,
    /// Number of modules that have at least one claim.
    pub modules_with_claims: usize,
    /// Number of modules with zero claims.
    pub modules_without_claims: usize,
}

/// Derive a module path from a file path.
///
/// Takes the first 2 directory segments after stripping common prefixes like `src/`.
/// Examples:
/// - `src/wallet/atomics/sync.rs` → `wallet/atomics`
/// - `src/tls/config.rs` → `tls`
/// - `config.toml` → `(root)`
fn derive_module(file_path: &str) -> String {
    let path = file_path
        .strip_prefix("src/")
        .or_else(|| file_path.strip_prefix("lib/"))
        .unwrap_or(file_path);

    let segments: Vec<&str> = path.split('/').filter(|s| !s.is_empty()).collect();

    // Take directory segments only (skip the filename)
    let dir_segments: Vec<&str> = if segments.len() > 1 {
        segments[..segments.len() - 1].to_vec()
    } else {
        return "(root)".to_string();
    };

    // Take up to 2 directory segments
    let module_depth = dir_segments.len().min(2);
    if module_depth == 0 {
        "(root)".to_string()
    } else {
        dir_segments[..module_depth].join("/")
    }
}

/// Derive a module path from a claim's concept_path.
///
/// Uses `tail_path` to get the last 2 segments, then takes the first segment
/// as the module. For claims without a valid tail path, uses the full concept_path.
fn derive_module_from_claim(concept_path: &str) -> String {
    if let Some(tp) = tail_path(concept_path) {
        // tail_path gives us "penultimate/last" — use the penultimate as module
        if let Some(slash) = tp.find('/') {
            tp[..slash].to_string()
        } else {
            tp
        }
    } else {
        // Fallback: strip scheme, use what we have
        let path = concept_path.find("://").map(|i| &concept_path[i + 3..]).unwrap_or(concept_path);
        path.to_string()
    }
}

/// Compute coverage metrics from claims, observations, and verification results.
pub fn compute_coverage(
    claims: &[AuthoredClaim],
    observations: &[Observation],
    project_name: &str,
) -> CoverageReport {
    let report = verify_claims(claims, observations);
    compute_coverage_from_report(claims, observations, &report, project_name)
}

/// Compute coverage from pre-computed verification report.
///
/// Useful when the caller already has a `VerifyReport` and doesn't want
/// to re-run verification.
pub fn compute_coverage_from_report(
    claims: &[AuthoredClaim],
    observations: &[Observation],
    report: &VerifyReport,
    project_name: &str,
) -> CoverageReport {
    // Group observations by module (from file path)
    let mut obs_by_module: BTreeMap<String, Vec<&Observation>> = BTreeMap::new();
    for obs in observations {
        let module = derive_module(&obs.file);
        obs_by_module.entry(module).or_default().push(obs);
    }

    // Build claim-to-module mapping from verification results.
    // For claims with matching observations (Pass/Conflict), derive the module
    // from the observation's file path so claims land in the same bucket as
    // their observations. For Missing claims, fall back to concept_path.
    let mut claim_to_module: std::collections::HashMap<String, String> =
        std::collections::HashMap::new();
    let mut claimed_tails: std::collections::HashSet<String> = std::collections::HashSet::new();
    let mut missing_claim_ids: std::collections::HashSet<String> = std::collections::HashSet::new();

    for result in &report.results {
        match result.verdict {
            AuditVerdict::Pass | AuditVerdict::Conflict => {
                if let Some(ref claim) = result.claim {
                    if let Some(tp) = tail_path(&claim.concept_path) {
                        claimed_tails.insert(tp);
                    }
                    // Derive module from the first matching observation's file path
                    if let Some(obs) = result.matching_observations.first() {
                        claim_to_module.insert(claim.id.clone(), derive_module(&obs.file));
                    }
                }
            }
            AuditVerdict::Missing => {
                if let Some(ref claim) = result.claim {
                    missing_claim_ids.insert(claim.id.clone());
                }
            }
            AuditVerdict::Unclaimed => {}
        }
    }

    // Group claims by module, using observation-derived module when available
    let mut claims_by_module: BTreeMap<String, Vec<&AuthoredClaim>> = BTreeMap::new();
    for claim in claims {
        if claim.status == crate::types::ClaimStatus::Active {
            let module = claim_to_module
                .get(&claim.id)
                .cloned()
                .unwrap_or_else(|| derive_module_from_claim(&claim.concept_path));
            claims_by_module.entry(module).or_default().push(claim);
        }
    }

    // Collect all module names from both observations and claims
    let mut all_modules: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
    for key in obs_by_module.keys() {
        all_modules.insert(key.clone());
    }
    for key in claims_by_module.keys() {
        all_modules.insert(key.clone());
    }

    // Build per-module coverage
    let mut modules = Vec::new();
    let mut total_observations = 0usize;
    let mut total_claimed = 0usize;
    let mut total_unclaimed = 0usize;
    let mut modules_with_claims = 0usize;
    let mut modules_without_claims = 0usize;

    for module in &all_modules {
        let obs_list = obs_by_module.get(module);
        let claim_list = claims_by_module.get(module);

        let observation_count = obs_list.map(|v| v.len()).unwrap_or(0);
        let claim_count = claim_list.map(|v| v.len()).unwrap_or(0);

        // Count how many observations in this module are claimed
        let claimed_obs = obs_list
            .map(|obs| {
                obs.iter()
                    .filter(|o| {
                        tail_path(&o.concept_path)
                            .map(|tp| claimed_tails.contains(&tp))
                            .unwrap_or(false)
                    })
                    .count()
            })
            .unwrap_or(0);

        let unclaimed_obs = observation_count.saturating_sub(claimed_obs);

        // Count missing claims in this module
        let missing_in_module = claim_list
            .map(|cls| cls.iter().filter(|c| missing_claim_ids.contains(&c.id)).count())
            .unwrap_or(0);

        let density =
            if observation_count > 0 { claim_count as f32 / observation_count as f32 } else { 0.0 };

        // Collect unique files in this module
        let files: Vec<String> = obs_list
            .map(|obs| {
                let mut file_set: std::collections::BTreeSet<String> =
                    std::collections::BTreeSet::new();
                for o in obs {
                    file_set.insert(o.file.clone());
                }
                file_set.into_iter().collect()
            })
            .unwrap_or_default();

        if claim_count > 0 {
            modules_with_claims += 1;
        } else {
            modules_without_claims += 1;
        }

        total_observations += observation_count;
        total_claimed += claimed_obs;
        total_unclaimed += unclaimed_obs;

        modules.push(ModuleCoverage {
            module_path: module.clone(),
            files,
            observation_count,
            claim_count,
            claimed_observations: claimed_obs,
            unclaimed_observations: unclaimed_obs,
            missing_claims: missing_in_module,
            density,
        });
    }

    let active_claims =
        claims.iter().filter(|c| c.status == crate::types::ClaimStatus::Active).count();

    let claimed_percentage = if total_observations > 0 {
        (total_claimed as f32 / total_observations as f32) * 100.0
    } else {
        0.0
    };

    CoverageReport {
        project: project_name.to_string(),
        modules,
        summary: CoverageSummary {
            total_observations,
            total_claims: active_claims,
            claimed_percentage,
            unclaimed_count: total_unclaimed,
            modules_with_claims,
            modules_without_claims,
        },
    }
}

/// Format coverage report as a terminal table.
pub fn format_coverage_table(report: &CoverageReport, sort_by: &str) -> String {
    let mut out = String::new();

    out.push_str(&format!("Aphoria Coverage: {}\n\n", report.project));

    if report.modules.is_empty() {
        out.push_str("No observations or claims found.\n");
        return out;
    }

    let mut modules = report.modules.clone();
    match sort_by {
        "unclaimed" => {
            modules.sort_by(|a, b| b.unclaimed_observations.cmp(&a.unclaimed_observations))
        }
        "observations" => modules.sort_by(|a, b| b.observation_count.cmp(&a.observation_count)),
        "density" => modules.sort_by(|a, b| {
            b.density
                .partial_cmp(&a.density)
                .unwrap_or(std::cmp::Ordering::Equal)
                .then_with(|| b.observation_count.cmp(&a.observation_count))
        }),
        _ => {} // default: alphabetical (already sorted by BTreeMap)
    }

    let mut table = comfy_table::Table::new();
    table.set_header(vec![
        "Module",
        "Claims",
        "Observations",
        "Claimed",
        "Unclaimed",
        "Missing",
        "Density",
    ]);

    for m in &modules {
        table.add_row(vec![
            m.module_path.clone(),
            m.claim_count.to_string(),
            m.observation_count.to_string(),
            m.claimed_observations.to_string(),
            m.unclaimed_observations.to_string(),
            m.missing_claims.to_string(),
            format!("{:.1}%", m.density * 100.0),
        ]);
    }

    out.push_str(&table.to_string());
    out.push_str(&format!(
        "\n\nSummary: {} claims, {} observations, {:.1}% claimed, {} unclaimed",
        report.summary.total_claims,
        report.summary.total_observations,
        report.summary.claimed_percentage,
        report.summary.unclaimed_count,
    ));
    out.push_str(&format!(
        "\nModules: {} with claims, {} without claims",
        report.summary.modules_with_claims, report.summary.modules_without_claims,
    ));

    out
}

/// Format coverage report as JSON.
pub fn format_coverage_json(report: &CoverageReport) -> String {
    serde_json::to_string_pretty(report).unwrap_or_else(|_| "{}".to_string())
}

/// Format coverage report as markdown.
pub fn format_coverage_markdown(report: &CoverageReport) -> String {
    let mut out = String::new();

    out.push_str(&format!("# Aphoria Coverage: {}\n\n", report.project));

    out.push_str("## Summary\n\n");
    out.push_str(&format!(
        "- **Claims:** {}\n- **Observations:** {}\n- **Claimed:** {:.1}%\n- **Unclaimed:** {}\n- **Modules with claims:** {}\n- **Modules without claims:** {}\n\n",
        report.summary.total_claims,
        report.summary.total_observations,
        report.summary.claimed_percentage,
        report.summary.unclaimed_count,
        report.summary.modules_with_claims,
        report.summary.modules_without_claims,
    ));

    if report.modules.is_empty() {
        out.push_str("No observations or claims found.\n");
        return out;
    }

    out.push_str("## Modules\n\n");
    out.push_str("| Module | Claims | Observations | Claimed | Unclaimed | Missing | Density |\n");
    out.push_str("|--------|--------|--------------|---------|-----------|---------|----------|\n");

    for m in &report.modules {
        out.push_str(&format!(
            "| {} | {} | {} | {} | {} | {} | {:.1}% |\n",
            m.module_path,
            m.claim_count,
            m.observation_count,
            m.claimed_observations,
            m.unclaimed_observations,
            m.missing_claims,
            m.density * 100.0,
        ));
    }

    // Highlight modules with 0 claims
    let uncovered: Vec<&ModuleCoverage> =
        report.modules.iter().filter(|m| m.claim_count == 0 && m.observation_count > 0).collect();

    if !uncovered.is_empty() {
        out.push_str("\n## Coverage Gaps\n\n");
        out.push_str("These modules have observations but no authored claims:\n\n");
        for m in uncovered {
            out.push_str(&format!(
                "- **{}** ({} unclaimed observations)\n",
                m.module_path, m.unclaimed_observations,
            ));
        }
    }

    out
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::types::authored_claim::{AuthoredValue, ClaimStatus, ComparisonMode};
    use stemedb_core::types::ObjectValue;

    fn make_claim(id: &str, concept_path: &str, category: &str) -> AuthoredClaim {
        AuthoredClaim {
            id: id.to_string(),
            concept_path: concept_path.to_string(),
            predicate: "test".to_string(),
            value: AuthoredValue::Text("test".to_string()),
            comparison: ComparisonMode::Equals,
            provenance: "test".to_string(),
            invariant: "test".to_string(),
            consequence: "test".to_string(),
            authority_tier: "expert".to_string(),
            evidence: vec![],
            category: category.to_string(),
            status: ClaimStatus::Active,
            supersedes: None,
            created_by: "tester".to_string(),
            created_at: "2026-02-08".to_string(),
            updated_at: None,
        }
    }

    fn make_obs(concept_path: &str, file: &str) -> Observation {
        Observation {
            concept_path: concept_path.to_string(),
            predicate: "test".to_string(),
            value: ObjectValue::Text("test".to_string()),
            file: file.to_string(),
            line: 1,
            matched_text: "test".to_string(),
            confidence: 1.0,
            description: "test".to_string(),
        }
    }

    #[test]
    fn test_derive_module() {
        assert_eq!(derive_module("src/wallet/atomics/sync.rs"), "wallet/atomics");
        assert_eq!(derive_module("src/tls/config.rs"), "tls");
        assert_eq!(derive_module("config.toml"), "(root)");
        assert_eq!(derive_module("src/main.rs"), "(root)");
        assert_eq!(derive_module("src/auth/jwt/token.rs"), "auth/jwt");
    }

    #[test]
    fn test_derive_module_from_claim() {
        assert_eq!(derive_module_from_claim("project/wallet/atomics/ordering"), "atomics");
        assert_eq!(derive_module_from_claim("code://rust/core/imports/tokio"), "imports");
    }

    #[test]
    fn test_compute_coverage_empty() {
        let report = compute_coverage(&[], &[], "test");
        assert_eq!(report.summary.total_observations, 0);
        assert_eq!(report.summary.total_claims, 0);
        assert_eq!(report.summary.claimed_percentage, 0.0);
    }

    #[test]
    fn test_compute_coverage_with_matches() {
        let claims = vec![make_claim("c1", "project/atomics/ordering", "safety")];
        let observations = vec![
            make_obs("code://rust/project/atomics/ordering", "src/wallet/atomics/sync.rs"),
            make_obs("code://rust/project/tls/config", "src/tls/config.rs"),
        ];

        let report = compute_coverage(&claims, &observations, "test");
        assert_eq!(report.summary.total_claims, 1);
        assert_eq!(report.summary.total_observations, 2);
        assert!(report.summary.unclaimed_count > 0);
    }

    #[test]
    fn test_coverage_table_output() {
        let claims = vec![make_claim("c1", "project/atomics/ordering", "safety")];
        let observations =
            vec![make_obs("code://rust/project/atomics/ordering", "src/wallet/atomics/sync.rs")];
        let report = compute_coverage(&claims, &observations, "myproject");
        let table = format_coverage_table(&report, "name");
        assert!(table.contains("Aphoria Coverage: myproject"));
        assert!(table.contains("Summary:"));
    }

    #[test]
    fn test_coverage_json_output() {
        let report = compute_coverage(&[], &[], "test");
        let json = format_coverage_json(&report);
        let parsed: serde_json::Value = serde_json::from_str(&json).expect("valid json");
        assert_eq!(parsed["project"], "test");
    }

    #[test]
    fn test_coverage_markdown_output() {
        let report = compute_coverage(&[], &[], "test");
        let md = format_coverage_markdown(&report);
        assert!(md.starts_with("# Aphoria Coverage: test"));
    }

    #[test]
    fn test_deprecated_claims_excluded() {
        let mut claim = make_claim("c1", "project/atomics/ordering", "safety");
        claim.status = ClaimStatus::Deprecated;
        let report = compute_coverage(&[claim], &[], "test");
        assert_eq!(report.summary.total_claims, 0);
    }

    #[test]
    fn test_claims_map_to_observation_modules() {
        // Claim concept_path and observation concept_path share tail "atomics/ordering"
        let claims = vec![make_claim("c1", "project/atomics/ordering", "safety")];
        let observations =
            vec![make_obs("code://rust/project/atomics/ordering", "src/wallet/atomics/sync.rs")];

        let report = compute_coverage(&claims, &observations, "test");

        // The claim should land in "wallet/atomics" (from observation file path),
        // NOT "atomics" (from concept_path tail). This means the module should
        // have both a claim and an observation with non-zero density.
        let wallet_mod = report.modules.iter().find(|m| m.module_path == "wallet/atomics");
        assert!(wallet_mod.is_some(), "Expected wallet/atomics module");
        let Some(wallet_mod) = wallet_mod else {
            panic!("wallet/atomics module not found");
        };
        assert_eq!(wallet_mod.claim_count, 1);
        assert_eq!(wallet_mod.observation_count, 1);
        assert!(wallet_mod.density > 0.0, "density should be non-zero");
    }
}
