"""
Coverage analysis utilities.

Provides:
- get_uncovered_lines: Find uncovered changed lines using diff-cover
- get_complex_uncovered: Find high-complexity functions with low coverage
- get_new_untested_functions: Find new functions without test coverage
- get_surviving_mutations: Run mutation testing
- get_current_branch: Get current git branch
"""

import ast
import json
import xml.etree.ElementTree as ET
from pathlib import Path

from .utils import get_cache_dir, run_command


def get_current_branch(project_path: str) -> str:
    """Get the current git branch for a project."""
    _, stdout, _ = run_command(
        ["git", "rev-parse", "--abbrev-ref", "HEAD"], cwd=project_path
    )
    return stdout.strip() if stdout.strip() else "main"


def _normalize_path(path: str) -> str:
    """Normalize a file path for consistent matching."""
    path = path.lstrip("./")
    if path.startswith("src/"):
        path = path[4:]
    return path


def _find_coverage_match(rel_path: str, covered_lines: dict[str, set]) -> set | None:
    """Find matching coverage data for a path, trying multiple variations.

    Returns the covered lines set if found, None if file not in coverage data.
    """
    normalized = _normalize_path(rel_path)

    if rel_path in covered_lines:
        return covered_lines[rel_path]

    if normalized in covered_lines:
        return covered_lines[normalized]

    src_path = f"src/{normalized}"
    if src_path in covered_lines:
        return covered_lines[src_path]

    parts = normalized.split("/")
    for i in range(len(parts)):
        suffix = "/".join(parts[i:])
        for cov_path in covered_lines:
            if cov_path.endswith(suffix) and len(suffix) > 5:
                return covered_lines[cov_path]

    return None


def get_uncovered_lines(
    project_path: str, compare_branch: str, coverage_file: Path
) -> list[dict]:
    """Get uncovered changed lines using diff-cover."""
    gaps = []

    cmd = [
        "diff-cover",
        str(coverage_file),
        f"--compare-branch={compare_branch}",
        "--json-report=/dev/stdout",
    ]

    _, stdout, _ = run_command(cmd, cwd=project_path)

    try:
        data = json.loads(stdout)
        for filepath, file_stats in data.get("src_stats", {}).items():
            if file_stats.get("violation_lines"):
                coverage_pct = file_stats.get("percent_covered", 0)
                gaps.append(
                    {
                        "type": "structural",
                        "file": filepath,
                        "lines": file_stats["violation_lines"],
                        "reason": "uncovered changed lines",
                        "priority": "high" if coverage_pct < 50 else "medium",
                        "coverage": round(coverage_pct, 1),
                    }
                )
    except json.JSONDecodeError:
        pass

    return gaps


def get_complex_uncovered(
    project_path: str, coverage_file: Path, min_complexity: int = 10
) -> list[dict]:
    """Find high-complexity functions with low coverage."""
    gaps = []

    cmd = ["radon", "cc", ".", "-j", "-a"]
    _, stdout, _ = run_command(cmd, cwd=project_path)

    try:
        complexity_data = json.loads(stdout)
    except json.JSONDecodeError:
        return gaps

    if not coverage_file.exists():
        return gaps

    try:
        tree = ET.parse(coverage_file)
        root = tree.getroot()

        covered_lines = {}
        executable_lines = {}
        for pkg in root.findall(".//package"):
            for cls in pkg.findall(".//class"):
                filename = cls.get("filename", "")
                normalized = _normalize_path(filename)
                covered = set()
                all_exec = set()
                for line in cls.findall(".//line"):
                    line_num = int(line.get("number", 0))
                    all_exec.add(line_num)
                    if int(line.get("hits", 0)) > 0:
                        covered.add(line_num)
                covered_lines[filename] = covered
                covered_lines[normalized] = covered
                executable_lines[filename] = all_exec
                executable_lines[normalized] = all_exec
    except ET.ParseError:
        return gaps

    for filepath, functions in complexity_data.items():
        rel_path = filepath.replace(project_path + "/", "").lstrip("./")

        if "test" in rel_path.lower() or rel_path.startswith("tests/"):
            continue

        file_covered = _find_coverage_match(rel_path, covered_lines)
        file_executable = _find_coverage_match(rel_path, executable_lines)

        if file_covered is None or file_executable is None:
            continue

        for func in functions:
            complexity = func.get("complexity", 0)
            if complexity < min_complexity:
                continue

            start_line = func.get("lineno", 0)
            end_line = func.get("endline", start_line + 10)
            func_line_range = set(range(start_line, end_line + 1))

            func_executable = func_line_range & file_executable
            func_covered = func_line_range & file_covered

            total_executable = len(func_executable)
            covered_count = len(func_covered)
            func_coverage = (
                (covered_count / total_executable * 100)
                if total_executable > 0
                else 100
            )

            if func_coverage < 80:
                gaps.append(
                    {
                        "type": "structural",
                        "file": rel_path,
                        "function": func.get("name"),
                        "line": start_line,
                        "reason": f"high complexity ({complexity}), low coverage ({func_coverage:.0f}%)",
                        "priority": "high" if func_coverage < 50 else "medium",
                        "complexity": complexity,
                        "coverage": round(func_coverage, 1),
                    }
                )

    return gaps


def get_new_untested_functions(
    project_path: str, compare_branch: str, coverage_file: Path
) -> list[dict]:
    """Find new functions that have no test coverage."""
    gaps = []

    if not coverage_file.exists():
        return gaps

    cmd = ["git", "diff", "--name-only", compare_branch, "--", "*.py"]
    _, stdout, _ = run_command(cmd, cwd=project_path)

    changed_files = [f.strip() for f in stdout.strip().split("\n") if f.strip()]
    changed_files = [
        f for f in changed_files if not f.startswith("test") and "test" not in f
    ]

    if not changed_files:
        return gaps

    for filepath in changed_files:
        cmd = ["git", "diff", compare_branch, "--", filepath]
        _, diff_output, _ = run_command(cmd, cwd=project_path)

        new_functions = []
        for line in diff_output.split("\n"):
            if line.startswith("+") and not line.startswith("+++"):
                stripped = line[1:].strip()
                if stripped.startswith("def ") or stripped.startswith("async def "):
                    if "def " in stripped:
                        func_name = stripped.split("def ")[1].split("(")[0].strip()
                        new_functions.append(func_name)

        if not new_functions:
            continue

        full_path = Path(project_path) / filepath
        if not full_path.exists():
            continue

        try:
            with open(full_path) as f:
                tree = ast.parse(f.read())
        except (SyntaxError, FileNotFoundError):
            continue

        try:
            cov_tree = ET.parse(coverage_file)
            root = cov_tree.getroot()

            coverage_by_file = {}
            for pkg in root.findall(".//package"):
                for cls in pkg.findall(".//class"):
                    cov_filename = cls.get("filename", "")
                    normalized = _normalize_path(cov_filename)
                    lines = set()
                    for line in cls.findall(".//line"):
                        if int(line.get("hits", 0)) > 0:
                            lines.add(int(line.get("number", 0)))
                    coverage_by_file[cov_filename] = lines
                    coverage_by_file[normalized] = lines

            covered_lines = _find_coverage_match(filepath, coverage_by_file)
            if covered_lines is None:
                covered_lines = set()

            for node in ast.walk(tree):
                if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
                    if node.name in new_functions:
                        func_lines = set(range(node.lineno, node.end_lineno + 1))
                        if not (func_lines & covered_lines):
                            gaps.append(
                                {
                                    "type": "structural",
                                    "file": filepath,
                                    "function": node.name,
                                    "line": node.lineno,
                                    "reason": "new function with no test coverage",
                                    "priority": "high",
                                }
                            )
        except ET.ParseError:
            continue

    return gaps


def get_surviving_mutations(
    project_path: str, paths_to_mutate: str | None = None
) -> list[dict]:
    """Run mutation testing and return surviving mutants.

    Note: mutmut creates .mutmut-cache/ in the project directory.
    """
    gaps = []
    cache_dir = get_cache_dir(project_path)

    cmd = ["mutmut", "run", "--no-progress", "--CI"]
    if paths_to_mutate:
        cmd.extend(["--paths-to-mutate", paths_to_mutate])

    env = {"COVERAGE_FILE": str(cache_dir / ".coverage.mutmut")}
    run_command(cmd, cwd=project_path, timeout=1800, env=env)

    cmd = ["mutmut", "results"]
    _, stdout, _ = run_command(cmd, cwd=project_path, env=env)

    if "survived" in stdout.lower():
        lines = stdout.strip().split("\n")
        for line in lines:
            if line.strip() and "survived" in line.lower():
                gaps.append(
                    {
                        "type": "structural",
                        "reason": "mutation survived - tests don't verify behavior",
                        "priority": "high",
                        "details": line.strip(),
                    }
                )

    return gaps


def get_recently_changed_files(project_path: str, compare_branch: str) -> set[str]:
    """Get files changed since compare_branch."""
    cmd = ["git", "diff", "--name-only", compare_branch]
    _, stdout, _ = run_command(cmd, cwd=project_path)
    return set(f.strip() for f in stdout.strip().split("\n") if f.strip())


def compute_priority_score(gap: dict, recently_changed_files: set[str]) -> int:
    """Compute priority score for a gap.

    Scoring:
    - Recently changed: +30
    - Low coverage (<50%): +20
    - High complexity (>15): +20
    - New function: +25
    - Medium priority: +10
    - High priority: +15
    """
    score = 0
    file_path = gap.get("file", "")

    if file_path in recently_changed_files:
        score += 30

    coverage = gap.get("coverage", 100)
    if coverage < 50:
        score += 20
    elif coverage < 80:
        score += 10

    complexity = gap.get("complexity", 0)
    if complexity > 15:
        score += 20
    elif complexity > 10:
        score += 10

    if "new function" in gap.get("reason", ""):
        score += 25

    priority = gap.get("priority", "low")
    if priority == "high":
        score += 15
    elif priority == "medium":
        score += 10

    return score


def score_and_sort_gaps(
    gaps: list[dict], recently_changed_files: set[str]
) -> list[dict]:
    """Score and sort gaps by priority."""
    for gap in gaps:
        gap["priority_score"] = compute_priority_score(gap, recently_changed_files)

    return sorted(gaps, key=lambda x: -x.get("priority_score", 0))
