"""Python coverage analysis check.

Output is designed to be prescriptive for AI agents:
- Shows exactly which lines need tests
- Removes meta-information that doesn't help fix the problem
- Provides copy-paste-ready line references
"""

import ast
import os
import re
import time
from pathlib import Path
from typing import List, Optional, Tuple, Union

from slopmop.checks.base import (
    BaseCheck,
    CheckRole,
    ConfigField,
    Flaw,
    GateCategory,
    GateLevel,
    RemediationChurn,
    ToolContext,
)
from slopmop.checks.constants import (
    SKIP_NOT_PYTHON_PROJECT,
    coverage_below_threshold_message,
    has_python_test_files,
    python_no_tests_fix_suggestion,
    skip_reason_no_test_files,
)
from slopmop.checks.mixins import PythonCheckMixin
from slopmop.constants import (
    COVERAGE_BELOW_THRESHOLD,
    COVERAGE_GUIDANCE_FOOTER,
    COVERAGE_MEETS_THRESHOLD,
    COVERAGE_STANDARDS_PREFIX,
    COVERAGE_XML_NOT_FOUND,
)
from slopmop.core.result import CheckResult, CheckStatus, Finding, FindingLevel

COVERAGE_THRESHOLD = 80
CI_BUFFER = 0.5  # CI environments get slight buffer for timing variance
COVERAGE_POLL_TIMEOUT = 10  # seconds to wait for coverage.xml
MAX_FILES_TO_SHOW = 5  # Limit files shown to avoid overwhelming output


def _wait_for_coverage_xml(path: str) -> bool:
    """Poll for coverage.xml to appear when running alongside python-tests.

    In parallel mode, python-tests may still be generating .coverage and
    coverage.xml when this check starts.
    """
    deadline = time.time() + COVERAGE_POLL_TIMEOUT
    while time.time() < deadline:
        if os.path.exists(path) and os.path.getsize(path) > 0:
            return True
        time.sleep(0.5)
    return False


def _get_compare_branch() -> str:
    """Resolve the branch to diff against.

    Precedence: COMPARE_BRANCH env → GITHUB_BASE_REF (set in PR CI) → origin/main.

    GITHUB_BASE_REF is a bare branch name (e.g. 'main'), not a remote
    tracking ref.  diff-cover needs 'origin/main' so it can resolve
    commits via 'origin/main...HEAD'.  We prefix automatically.
    """
    explicit = os.environ.get("COMPARE_BRANCH")
    if explicit:
        return explicit
    base_ref = os.environ.get("GITHUB_BASE_REF")
    if base_ref:
        return f"origin/{base_ref}"
    return "origin/main"


class PythonCoverageCheck(BaseCheck, PythonCheckMixin):
    """Python test coverage enforcement.

    Wraps coverage.py to verify project-wide coverage meets the
    threshold. Reads coverage.xml generated by the overconfidence:untested-code.py gate.
    Output is prescriptive: shows exactly which files and lines need
    tests, sorted by largest coverage gap first.

    Level: scour

    Configuration:
      threshold: 80 — industry standard for mature projects.
          Start lower (30-50) on legacy codebases and ramp up.
          See README "Gradual Ramp" section.

    Common failures:
      Below threshold: The output lists files and missing line
          ranges. Write tests that exercise those lines.
      coverage.xml not found: The overconfidence:untested-code.py gate must run first
          to generate coverage data.

    Re-check:
      sm swab -g overconfidence:coverage-gaps.py --verbose
    """

    tool_context = ToolContext.PROJECT
    role = CheckRole.FOUNDATION
    remediation_churn = RemediationChurn.DOWNSTREAM_CHANGES_UNLIKELY

    DEFAULT_THRESHOLD = 80

    @property
    def name(self) -> str:
        return "coverage-gaps.py"

    @property
    def display_name(self) -> str:
        return "📊 Coverage (Python, pytest-cov)"

    @property
    def gate_description(self) -> str:
        return "📊 Whole-repo coverage (80% default threshold)"

    @property
    def category(self) -> GateCategory:
        return GateCategory.OVERCONFIDENCE

    @property
    def flaw(self) -> Flaw:
        return Flaw.OVERCONFIDENCE

    @property
    def depends_on(self) -> List[str]:
        return ["overconfidence:untested-code.py"]

    @property
    def config_schema(self) -> List[ConfigField]:
        return [
            ConfigField(
                name="threshold",
                field_type="integer",
                default=80,
                description="Minimum coverage percentage required",
                min_value=0,
                max_value=100,
                permissiveness="higher_is_stricter",
            ),
        ]

    def is_applicable(self, project_root: str) -> bool:
        """Applicable to Python projects; run() enforces test presence."""
        return self.is_python_project(project_root)

    def skip_reason(self, project_root: str) -> str:
        """Return skip reason when coverage prerequisites are missing."""
        if not self.is_python_project(project_root):
            return SKIP_NOT_PYTHON_PROJECT
        return "Python coverage check not applicable"

    def run(self, project_root: str) -> CheckResult:
        """Analyze coverage data and provide prescriptive output.

        Instead of meta-information, outputs exactly which lines need tests.
        """
        start_time = time.time()
        test_dirs = self.config.get("test_dirs", ["tests"])
        if not has_python_test_files(project_root, test_dirs):
            message = skip_reason_no_test_files(test_dirs)
            return self._create_result(
                status=CheckStatus.FAILED,
                duration=time.time() - start_time,
                error=message,
                output=message,
                fix_suggestion=python_no_tests_fix_suggestion(
                    test_dirs, self.verify_command
                ),
                findings=[Finding(message=message, level=FindingLevel.ERROR)],
            )

        # PROJECT check: bail early when no project venv exists
        venv_warn = self.check_project_venv_or_warn(project_root, start_time)
        if venv_warn is not None:
            return venv_warn

        # Check for coverage.xml
        coverage_file = Path(project_root) / "coverage.xml"
        if not coverage_file.exists():
            return self._create_result(
                status=CheckStatus.FAILED,
                duration=time.time() - start_time,
                output="",
                error=COVERAGE_XML_NOT_FOUND,
                fix_suggestion="Run python-tests check first to generate coverage data",
                findings=[
                    Finding(message=COVERAGE_XML_NOT_FOUND, level=FindingLevel.ERROR)
                ],
            )

        # Get coverage report with missing lines
        result = self._run_command(
            [
                self.get_project_python(project_root),
                "-m",
                "coverage",
                "report",
                "--show-missing",
                "--fail-under=0",
            ],
            cwd=project_root,
            timeout=30,
        )

        duration = time.time() - start_time

        # Parse coverage percentage
        coverage_pct = self._parse_coverage(result.output)
        threshold = self.config.get("threshold", self.DEFAULT_THRESHOLD)

        if coverage_pct is None:
            return self._create_result(
                status=CheckStatus.ERROR,
                duration=duration,
                output=result.output,
                error="Could not parse coverage percentage",
            )

        if coverage_pct >= threshold:
            return self._create_result(
                status=CheckStatus.PASSED,
                duration=duration,
                output=COVERAGE_MEETS_THRESHOLD,
            )

        return self._build_failure(
            coverage_pct, threshold, result.output, duration, project_root
        )

    def _build_failure(
        self,
        coverage_pct: float,
        threshold: int,
        raw_output: str,
        duration: float,
        project_root: str,
    ) -> CheckResult:
        """Construct the below-threshold failure result with per-file findings.

        Each per-file finding gets a fix_strategy pointing at the
        conventional test-file location + the exact uncovered line range.
        We DON'T guess what the test should assert — only where to write
        it and which lines it must exercise.  Anything further is
        judgment the agent brings.

        The test-file convention used is ``tests/test_{stem}.py`` for
        ordinary modules, ``tests/test_{pkg}.py`` for ``__init__.py``
        (package name, not the literal "__init__").  When neither
        convention yields a sensible target — e.g. a top-level
        ``__init__.py`` with no meaningful parent — strategy stays
        None.  Better to say nothing than to guess a garbage path.
        """
        missing_files = self._parse_missing_lines(raw_output)
        prescriptive_output = self._format_prescriptive_output(
            coverage_pct, threshold, missing_files
        )

        per_file: List[Finding] = []
        for fp, _stmts, miss, ranges in missing_files:
            target = _test_file_for(fp)
            # Try AST resolution first for richer fix_strategy
            ast_strategy = _resolve_uncovered_range(project_root, fp, ranges)
            per_file.append(
                Finding(
                    message=f"{miss} uncovered lines: {ranges}",
                    file=fp,
                    fix_strategy=(
                        ast_strategy
                        if ast_strategy
                        else (
                            (
                                f"Add tests in {target} that exercise "
                                f"lines {ranges} of {fp}"
                            )
                            if target
                            else None
                        )
                    ),
                )
            )
        # Fallback when per-file parsing yields nothing (e.g. minimal
        # coverage output that only contains the TOTAL line).
        if not per_file:
            per_file = [
                Finding(
                    message=coverage_below_threshold_message(coverage_pct, threshold),
                )
            ]

        return self._create_result(
            status=CheckStatus.FAILED,
            duration=duration,
            output=prescriptive_output,
            error=COVERAGE_BELOW_THRESHOLD,
            fix_suggestion=(
                "Each uncovered range above is resolved to its "
                "enclosing function where possible. Write tests that "
                "exercise those paths. Verify with: " + self.verify_command
            ),
            findings=per_file,
        )

    def _parse_missing_lines(self, output: str) -> List[Tuple[str, int, int, str]]:
        """Parse missing line info from coverage report.

        Returns list of (file, stmts, missing_count, missing_lines).
        Sorted by missing_count descending (biggest gaps first).
        """
        results: List[Tuple[str, int, int, str]] = []
        # Pattern: filename  stmts  miss  [branch  brpart]  cover%  missing
        # With branch=false:  slopmop/sb.py   456    386    15%   45-67, 89
        # With branch=true:   slopmop/sb.py   456    386   100   20   15%   45-67, 89
        # (?:\s+\d+)* absorbs optional Branch/BrPart columns.
        pattern = re.compile(
            r"^(\S+\.py)\s+(\d+)\s+(\d+)(?:\s+\d+)*\s+\d+%\s+(.+)$", re.MULTILINE
        )

        for match in pattern.finditer(output):
            filepath = match.group(1)
            stmts = int(match.group(2))
            missing = int(match.group(3))
            missing_lines = match.group(4).strip()

            # Skip files with 100% coverage or no missing lines
            if missing > 0 and missing_lines:
                results.append((filepath, stmts, missing, missing_lines))

        # Sort by missing count (biggest gaps first)
        results.sort(key=lambda x: x[2], reverse=True)
        return results

    def _format_prescriptive_output(
        self,
        coverage_pct: float,
        threshold: int,
        missing_files: List[Tuple[str, int, int, str]],
    ) -> str:
        """Format prescriptive output telling exactly what to do.

        No meta-info, just: "Add tests for these lines"
        """
        lines: List[str] = []
        lines.append(
            COVERAGE_STANDARDS_PREFIX
            + "Add high-quality test coverage to the following areas:"
        )
        lines.append("")

        # Show top files with most missing coverage
        for filepath, _stmts, _missing, missing_lines in missing_files[
            :MAX_FILES_TO_SHOW
        ]:
            lines.append(f"  {filepath}")
            lines.append(f"    Lines: {missing_lines}")
            lines.append("")

        if len(missing_files) > MAX_FILES_TO_SHOW:
            remaining = len(missing_files) - MAX_FILES_TO_SHOW
            lines.append(f"  ... and {remaining} more files")
            lines.append("")

        lines.append(COVERAGE_GUIDANCE_FOOTER)
        lines.append("")

        return "\n".join(lines)

    def _parse_coverage(self, output: str) -> Optional[float]:
        """Parse coverage percentage from output."""
        # Look for TOTAL line with percentage.
        # (?:\s+\d+)* absorbs optional Branch/BrPart columns so this
        # works with or without branch = true in coverage config.
        match = re.search(r"TOTAL\s+\d+\s+\d+(?:\s+\d+)*\s+(\d+(?:\.\d+)?)%", output)
        if match:
            return float(match.group(1))

        # Fallback: look for any percentage
        match = re.search(r"(\d+(?:\.\d+)?)%", output)
        if match:
            return float(match.group(1))

        return None


def _parse_diff_cover_files(output: str) -> List[Finding]:
    """Parse diff-cover output for per-file coverage violations.

    diff-cover prints lines like:
        src/module.py (80.0%): Missing lines 12-15, 42
    or
        src/module.py (80.0%)

    Returns one Finding per file that appears in the output.
    """
    findings: List[Finding] = []
    # Pattern: indented filename followed by (NN.N%) and optional missing lines
    pattern = re.compile(
        r"^\s*(\S+\.py)\s+\((\d+(?:\.\d+)?)%\)(?::\s*Missing lines\s+(.+))?$",
        re.MULTILINE,
    )
    for m in pattern.finditer(output):
        filepath, pct, missing = m.group(1), m.group(2), m.group(3)
        msg = f"Coverage {pct}% on changed lines"
        if missing:
            msg += f" — missing: {missing.strip()}"
        findings.append(Finding(message=msg, file=filepath, level=FindingLevel.ERROR))
    return findings


class PythonDiffCoverageCheck(BaseCheck, PythonCheckMixin):
    """Coverage enforcement on changed files only.

    Wraps diff-cover to check coverage on files changed vs the
    compare branch. Ensures new/modified code is tested even when
    overall project coverage is acceptable.

    Level: scour (needs PR diff context)

    Configuration:
      Compare branch is resolved from: COMPARE_BRANCH env var,
      then GITHUB_BASE_REF (set in PR CI), then origin/main.
      Threshold: 80% on changed lines.

    Common failures:
      Changed lines below 80%: Write tests for the new code
          shown in the output. Focus on the specific lines listed.
      No diff: This is fine — no changed files means nothing to
          check. Gate passes automatically.

    Re-check:
      sm scour -g myopia:just-this-once.py --verbose
    """

    level = GateLevel.SCOUR
    tool_context = ToolContext.PROJECT
    role = CheckRole.FOUNDATION

    @property
    def name(self) -> str:
        return "just-this-once.py"

    @property
    def display_name(self) -> str:
        return f"📈 Diff Coverage ({COVERAGE_THRESHOLD}%)"

    @property
    def gate_description(self) -> str:
        return "📈 Coverage on changed lines only (diff-cover)"

    @property
    def category(self) -> GateCategory:
        return GateCategory.MYOPIA

    @property
    def flaw(self) -> Flaw:
        return Flaw.MYOPIA

    @property
    def depends_on(self) -> List[str]:
        return ["overconfidence:untested-code.py"]

    def is_applicable(self, project_root: str) -> bool:
        """Applicable to Python projects; run() enforces test presence."""
        return self.is_python_project(project_root)

    def skip_reason(self, project_root: str) -> str:
        """Return reason for skipping when not a Python project."""
        if not self.is_python_project(project_root):
            return SKIP_NOT_PYTHON_PROJECT
        return "Diff coverage check not applicable"

    def run(self, project_root: str) -> CheckResult:
        start_time = time.time()
        test_dirs = self.config.get("test_dirs", ["tests"])
        if not has_python_test_files(project_root, test_dirs):
            message = skip_reason_no_test_files(test_dirs)
            return self._create_result(
                status=CheckStatus.FAILED,
                duration=time.time() - start_time,
                error=message,
                output=message,
                fix_suggestion=python_no_tests_fix_suggestion(
                    test_dirs, self.verify_command
                ),
                findings=[Finding(message=message, level=FindingLevel.ERROR)],
            )

        # PROJECT check: bail early when no project venv exists
        venv_warn = self.check_project_venv_or_warn(project_root, start_time)
        if venv_warn is not None:
            return venv_warn

        coverage_file = os.path.join(project_root, "coverage.xml")

        if not _wait_for_coverage_xml(coverage_file):
            return self._create_result(
                status=CheckStatus.ERROR,
                duration=time.time() - start_time,
                error=COVERAGE_XML_NOT_FOUND,
                fix_suggestion="Run python-tests first to generate coverage data",
            )

        compare_branch = _get_compare_branch()
        cmd = [
            self.get_project_python(project_root),
            "-m",
            "diff_cover.diff_cover_tool",
            "coverage.xml",
            f"--compare-branch={compare_branch}",
            f"--fail-under={COVERAGE_THRESHOLD}",
        ]

        result = self._run_command(cmd, cwd=project_root, timeout=60)
        duration = time.time() - start_time

        if result.success:
            return self._create_result(
                status=CheckStatus.PASSED,
                duration=duration,
                output=f"Changed files have adequate coverage (vs {compare_branch})",
            )

        # diff-cover not finding changes is fine
        if "No diff" in result.output:
            return self._create_result(
                status=CheckStatus.PASSED,
                duration=duration,
                output="No changed files to check coverage on",
            )

        diff_findings = _parse_diff_cover_files(result.output)
        if not diff_findings:
            diff_findings = [
                Finding(
                    message=(f"Changed files have <{COVERAGE_THRESHOLD}% coverage"),
                    level=FindingLevel.ERROR,
                )
            ]

        return self._create_result(
            status=CheckStatus.FAILED,
            duration=duration,
            output=result.output,
            error=f"Changed files have <{COVERAGE_THRESHOLD}% coverage",
            fix_suggestion="Add tests for the new code shown above.",
            findings=diff_findings,
        )


def _test_file_for(source_path: str) -> Optional[str]:
    """Derive the conventional test-file path for a source module.

    Convention: ``foo/bar.py`` → ``tests/test_bar.py``.
    Package case: ``foo/bar/__init__.py`` → ``tests/test_bar.py`` (parent
    dir name — the package IS the unit under test, not the file literal).

    Returns None when no reasonable convention applies — a root-level
    ``__init__.py`` with no named parent, or a degenerate path.  Caller
    skips fix_strategy on None.  This is the no-guessing escape hatch:
    saying nothing is better than ``test___init__.py``.
    """
    p = Path(source_path)
    stem = p.stem
    if stem == "__init__":
        # Package __init__ — conventional test target is the package name
        pkg = p.parent.name
        if not pkg or pkg in (".", "/"):
            return None
        return f"tests/test_{pkg}.py"
    if not stem:
        return None
    return f"tests/test_{stem}.py"


def _first_range(ranges: str) -> Optional[Tuple[int, int]]:
    """Pick the first ``start-end`` (or single ``N``) chunk from a
    coverage ``missing`` string like ``"12-18, 42, 90-101"``.

    Returns ``None`` when nothing parses — never raises.  We use the
    FIRST range rather than all of them because fix_strategy text is a
    single sentence: pointing at one concrete block gives the agent a
    starting handle, and the full range list is already in ``message``.
    """
    for chunk in ranges.split(","):
        chunk = chunk.strip()
        m = re.match(r"^(\d+)(?:-(\d+))?$", chunk)
        if m:
            start = int(m.group(1))
            end = int(m.group(2)) if m.group(2) else start
            return start, end
    return None


def _characterise_block(func: ast.AST, start: int, end: int) -> Optional[str]:
    """Identify whether an uncovered range sits inside an ``except``
    clause or an ``if`` body — the two block types where the testing
    approach is obvious (trigger the exception / supply the branching
    input).  Returns a hint sentence or ``None``.
    """
    for node in ast.walk(func):
        lo = getattr(node, "lineno", None)
        hi = getattr(node, "end_lineno", None)
        if lo is None or hi is None:
            continue
        if lo <= start and end <= hi:
            if isinstance(node, ast.ExceptHandler):
                return " This is an except block — test by triggering " "the exception."
            if isinstance(node, ast.If):
                return (
                    " This is a conditional branch — test with input "
                    "that takes this path."
                )
    return None


def _resolve_uncovered_range(
    project_root: str, filepath: str, ranges: str
) -> Optional[str]:
    """Resolve an uncovered line range to its enclosing function name
    and, where possible, characterise the block type.

    Returns a fix_strategy string or ``None``.  ``None`` is honest:
    it means we couldn't locate the range inside a function (module-
    level code, unparseable file, file moved since coverage ran).
    Never guesses — a wrong function name is worse than no name.
    """
    span = _first_range(ranges)
    if span is None:
        return None
    start, end = span

    source_path = Path(project_root) / filepath
    try:
        source = source_path.read_text(encoding="utf-8")
        tree = ast.parse(source)
    except (OSError, SyntaxError, ValueError):
        return None

    enclosing: Optional[Union[ast.FunctionDef, ast.AsyncFunctionDef]] = None
    for node in ast.walk(tree):
        if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
            lo = node.lineno
            hi = getattr(node, "end_lineno", None)
            if hi is None:
                continue
            if lo <= start and end <= hi:
                if enclosing is None or lo > enclosing.lineno:
                    enclosing = node

    if enclosing is None:
        return None

    func_name = enclosing.name
    strategy = (
        f"Lines {start}-{end} in {func_name}() are uncovered. "
        f"Write a test that exercises this path."
    )
    hint = _characterise_block(enclosing, start, end)
    if hint:
        strategy += hint
    return strategy
