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

use anyhow::{Context, Result};
use serde::Deserialize;

use crate::model::{
    CoverageOpportunity, CoverageReport, FileTotals, MetricKind, OpportunityKind, SourceSpan,
};

use super::path::{lexical_normalize, relativize_absolute_path};

pub(crate) fn parse_with_repo_root(input: &str, repo_root: &Path) -> Result<CoverageReport> {
    let export: HashMap<String, HashMap<String, serde_json::Value>> =
        serde_json::from_str(input).context("failed to parse coverlet json")?;

    let mut opportunities = Vec::new();
    let mut line_totals_by_file = BTreeMap::new();
    let mut branch_totals_by_file = BTreeMap::new();
    let mut function_totals_by_file = BTreeMap::new();

    for classes_by_file in export.into_values() {
        for (file_name, class_value) in classes_by_file {
            let path = normalize_path(&file_name, repo_root);
            let mut line_hits_by_line = BTreeMap::<u32, bool>::new();
            let mut branch_records = Vec::<BranchRecord>::new();
            let mut function_records = Vec::<FunctionRecord>::new();

            let Some(classes) = class_value.as_object() else {
                continue;
            };

            for methods_value in classes.values() {
                let Some(methods) = methods_value.as_object() else {
                    continue;
                };
                for method_value in methods.values() {
                    let Ok(method) = serde_json::from_value::<CoverletMethod>(method_value.clone())
                    else {
                        continue;
                    };

                    for (&line_number, &hits) in &method.lines {
                        let covered = hits > 0;
                        line_hits_by_line
                            .entry(line_number)
                            .and_modify(|seen| *seen = *seen || covered)
                            .or_insert(covered);
                    }

                    branch_records.extend(method.branches);

                    let start_line = method.lines.keys().copied().min();
                    let end_line = method.lines.keys().copied().max();
                    if let (Some(start_line), Some(end_line)) = (start_line, end_line) {
                        let covered = method.lines.values().any(|hits| *hits > 0);
                        function_records.push(FunctionRecord {
                            start_line,
                            end_line,
                            covered,
                        });
                    }
                }
            }

            if !line_hits_by_line.is_empty() {
                let total = line_hits_by_line.len();
                let mut covered = 0usize;
                for (line_number, is_covered) in line_hits_by_line {
                    if is_covered {
                        covered += 1;
                    }
                    opportunities.push(CoverageOpportunity {
                        kind: OpportunityKind::Line,
                        span: SourceSpan {
                            path: path.clone(),
                            start_line: line_number,
                            end_line: line_number,
                        },
                        covered: is_covered,
                    });
                }
                line_totals_by_file.insert(path.clone(), FileTotals { covered, total });
            }

            if !branch_records.is_empty() {
                let mut covered = 0usize;
                let total = branch_records.len();
                for branch in branch_records {
                    let is_covered = branch.hits > 0;
                    if is_covered {
                        covered += 1;
                    }
                    opportunities.push(CoverageOpportunity {
                        kind: OpportunityKind::BranchOutcome,
                        span: SourceSpan {
                            path: path.clone(),
                            start_line: branch.line,
                            end_line: branch.line,
                        },
                        covered: is_covered,
                    });
                }
                branch_totals_by_file.insert(path.clone(), FileTotals { covered, total });
            }

            if !function_records.is_empty() {
                let mut covered = 0usize;
                let total = function_records.len();
                for function in function_records {
                    if function.covered {
                        covered += 1;
                    }
                    opportunities.push(CoverageOpportunity {
                        kind: OpportunityKind::Function,
                        span: SourceSpan {
                            path: path.clone(),
                            start_line: function.start_line,
                            end_line: function.end_line,
                        },
                        covered: function.covered,
                    });
                }
                function_totals_by_file.insert(path, FileTotals { covered, total });
            }
        }
    }

    let mut totals_by_file = BTreeMap::new();
    if !line_totals_by_file.is_empty() {
        totals_by_file.insert(MetricKind::Line, line_totals_by_file);
    }
    if !branch_totals_by_file.is_empty() {
        totals_by_file.insert(MetricKind::Branch, branch_totals_by_file);
    }
    if !function_totals_by_file.is_empty() {
        totals_by_file.insert(MetricKind::Function, function_totals_by_file);
    }

    Ok(CoverageReport {
        opportunities,
        totals_by_file,
    })
}

fn normalize_path(value: &str, repo_root: &Path) -> PathBuf {
    let normalized_value = value.replace('\\', "/");
    let repo_root_string = repo_root.to_string_lossy().replace('\\', "/");

    if let Some(stripped) = normalized_value
        .strip_prefix(&format!("{repo_root_string}/"))
        .or_else(|| normalized_value.strip_prefix(&repo_root_string))
    {
        let trimmed = stripped.trim_start_matches('/');
        return lexical_normalize(Path::new(trimmed));
    }

    relativize_absolute_path(Path::new(&normalized_value), repo_root)
}

#[derive(Debug, Deserialize)]
struct CoverletMethod {
    #[serde(rename = "Lines", deserialize_with = "deserialize_line_hits")]
    lines: HashMap<u32, u64>,
    #[serde(rename = "Branches", default)]
    branches: Vec<BranchRecord>,
}

#[derive(Debug, Deserialize)]
struct BranchRecord {
    #[serde(rename = "Line")]
    line: u32,
    #[serde(rename = "Hits")]
    hits: u64,
}

#[derive(Debug)]
struct FunctionRecord {
    start_line: u32,
    end_line: u32,
    covered: bool,
}

fn deserialize_line_hits<'de, D>(deserializer: D) -> Result<HashMap<u32, u64>, D::Error>
where
    D: serde::Deserializer<'de>,
{
    let parsed: HashMap<String, u64> = HashMap::deserialize(deserializer)?;
    parsed
        .into_iter()
        .map(|(line, hits)| {
            line.parse::<u32>()
                .map(|line_number| (line_number, hits))
                .map_err(serde::de::Error::custom)
        })
        .collect()
}

#[cfg(test)]
mod tests {
    use std::path::{Path, PathBuf};

    use crate::model::{MetricKind, OpportunityKind};

    use super::{normalize_path, parse_with_repo_root};

    #[test]
    fn parses_coverlet_lines_and_branches() {
        let input = r#"
        {
          "Demo.dll": {
            "/workspace/covgate/src/lib.cs": {
              "Demo.MathOps": {
                "System.Int32 Demo.MathOps::Add(System.Int32,System.Int32)": {
                  "Lines": {
                    "3": 1,
                    "4": 0
                  },
                  "Branches": [
                    {"Line": 4, "Hits": 1},
                    {"Line": 4, "Hits": 0}
                  ]
                }
              }
            }
          }
        }
        "#;

        let report = parse_with_repo_root(input, Path::new("/workspace/covgate"))
            .expect("coverlet json should parse");

        let line_totals = report
            .totals_by_file
            .get(&MetricKind::Line)
            .expect("line totals should exist")
            .get(&PathBuf::from("src/lib.cs"))
            .expect("file totals should exist");
        assert_eq!(line_totals.covered, 1);
        assert_eq!(line_totals.total, 2);

        let branch_totals = report
            .totals_by_file
            .get(&MetricKind::Branch)
            .expect("branch totals should exist")
            .get(&PathBuf::from("src/lib.cs"))
            .expect("file totals should exist");
        assert_eq!(branch_totals.covered, 1);
        assert_eq!(branch_totals.total, 2);

        let function_totals = report
            .totals_by_file
            .get(&MetricKind::Function)
            .expect("function totals should exist")
            .get(&PathBuf::from("src/lib.cs"))
            .expect("file totals should exist");
        assert_eq!(function_totals.covered, 1);
        assert_eq!(function_totals.total, 1);
    }

    #[test]
    fn computes_function_spans_from_method_lines() {
        let input = r#"
        {
          "Demo.dll": {
            "src/lib.cs": {
              "Demo.MathOps": {
                "Covered": {"Lines": {"10": 1, "11": 0, "15": 2}, "Branches": []},
                "Uncovered": {"Lines": {"20": 0, "21": 0}, "Branches": []}
              }
            }
          }
        }
        "#;

        let report = parse_with_repo_root(input, Path::new("/workspace/covgate"))
            .expect("coverlet json should parse");

        let function_ops: Vec<_> = report
            .opportunities
            .iter()
            .filter(|op| op.kind == OpportunityKind::Function)
            .collect();
        assert_eq!(function_ops.len(), 2);
        assert!(
            function_ops
                .iter()
                .any(|op| { op.span.start_line == 10 && op.span.end_line == 15 && op.covered })
        );
        assert!(
            function_ops
                .iter()
                .any(|op| { op.span.start_line == 20 && op.span.end_line == 21 && !op.covered })
        );
    }

    #[test]
    fn normalizes_windows_path_separators() {
        let repo_root = Path::new("C:/workspace/covgate");
        let normalized = normalize_path("C:\\workspace\\covgate\\src\\lib.cs", repo_root);
        assert_eq!(normalized, PathBuf::from("src/lib.cs"));
    }

    #[test]
    fn merges_duplicate_lines_across_methods() {
        let input = r#"
        {
          "Demo.dll": {
            "src/lib.cs": {
              "Demo.MathOps": {
                "M1": {"Lines": {"10": 0, "11": 1}, "Branches": []},
                "M2": {"Lines": {"10": 2}, "Branches": []}
              }
            }
          }
        }
        "#;

        let report = parse_with_repo_root(input, Path::new("/workspace/covgate"))
            .expect("coverlet json should parse");

        let line_totals = report
            .totals_by_file
            .get(&MetricKind::Line)
            .expect("line totals should exist")
            .get(&PathBuf::from("src/lib.cs"))
            .expect("file totals should exist");
        assert_eq!(line_totals.total, 2);
        assert_eq!(line_totals.covered, 2);
    }

    #[test]
    fn skips_non_object_class_or_method_entries() {
        let input = r#"
        {
          "Demo.dll": {
            "src/lib.cs": {
              "IgnoredClass": 5,
              "Demo.MathOps": {
                "IgnoredMethod": 3,
                "RealMethod": {"Lines": {"5": 1}, "Branches": []}
              }
            }
          }
        }
        "#;

        let report = parse_with_repo_root(input, Path::new("/workspace/covgate"))
            .expect("coverlet json should parse");
        let lines: Vec<_> = report
            .opportunities
            .iter()
            .filter(|op| op.kind == OpportunityKind::Line)
            .collect();
        assert_eq!(lines.len(), 1);
    }

    #[test]
    fn invalid_line_key_method_is_ignored() {
        let input = r#"
        {
          "Demo.dll": {
            "src/lib.cs": {
              "Demo.MathOps": {
                "BadMethod": {"Lines": {"not-a-line": 1}, "Branches": []},
                "GoodMethod": {"Lines": {"7": 1}, "Branches": []}
              }
            }
          }
        }
        "#;

        let report = parse_with_repo_root(input, Path::new("/workspace/covgate"))
            .expect("coverlet json should parse");
        let line_totals = report
            .totals_by_file
            .get(&MetricKind::Line)
            .expect("line totals should exist")
            .get(&PathBuf::from("src/lib.cs"))
            .expect("file totals should exist");
        assert_eq!(line_totals.total, 1);
        assert_eq!(line_totals.covered, 1);
    }

    #[test]
    fn keeps_absolute_paths_outside_repo_as_absolute() {
        let repo_root = Path::new("/workspace/covgate");
        let normalized = normalize_path("/tmp/other/src/lib.cs", repo_root);
        assert_eq!(normalized, PathBuf::from("/tmp/other/src/lib.cs"));
    }

    #[test]
    fn skips_function_metric_when_method_has_no_lines() {
        let input = r#"
        {
          "Demo.dll": {
            "src/lib.cs": {
              "Demo.MathOps": {
                "NoLines": {"Lines": {}, "Branches": []}
              }
            }
          }
        }
        "#;

        let report = parse_with_repo_root(input, Path::new("/workspace/covgate"))
            .expect("coverlet json should parse");

        assert!(!report.totals_by_file.contains_key(&MetricKind::Function));
    }
}
