"""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 os
import re
import time
from pathlib import Path
from typing import List, Optional, Tuple

from slopmop.checks.base import (
    BaseCheck,
    ConfigField,
    Flaw,
    GateCategory,
    GateLevel,
    PythonCheckMixin,
    ToolContext,
)
from slopmop.checks.constants import (
    SKIP_NOT_PYTHON_PROJECT,
    has_python_test_files,
    skip_reason_no_test_files,
)
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

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.
    """
    return (
        os.environ.get("COMPARE_BRANCH")
        or os.environ.get("GITHUB_BASE_REF")
        or "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

    DEFAULT_THRESHOLD = 80

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

    @property
    def display_name(self) -> str:
        return "📊 Coverage"

    @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 only if there are Python test files (coverage needs tests)."""
        if not self.is_python_project(project_root):
            return False
        test_dirs = self.config.get("test_dirs", ["tests"])
        return has_python_test_files(project_root, test_dirs)

    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
        test_dirs = self.config.get("test_dirs", ["tests"])
        return skip_reason_no_test_files(test_dirs)

    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()

        # 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",
            )

        # 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,
            )

        # Coverage below threshold - provide prescriptive output
        missing_files = self._parse_missing_lines(result.output)
        prescriptive_output = self._format_prescriptive_output(
            coverage_pct, threshold, missing_files
        )

        return self._create_result(
            status=CheckStatus.FAILED,
            duration=duration,
            output=prescriptive_output,
            error=COVERAGE_BELOW_THRESHOLD,
            fix_suggestion="Add tests for the files and lines listed above.",
        )

    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  cover  missing
        # slopmop/sb.py   456    386    15%   45-67, 89, 120-150
        pattern = re.compile(r"^(\S+\.py)\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
        match = re.search(r"TOTAL\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


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

    @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 only if there are Python test files (coverage needs tests)."""
        if not self.is_python_project(project_root):
            return False
        test_dirs = self.config.get("test_dirs", ["tests"])
        return has_python_test_files(project_root, test_dirs)

    def skip_reason(self, project_root: str) -> str:
        """Return reason for skipping - no Python test files."""
        if not self.is_python_project(project_root):
            return SKIP_NOT_PYTHON_PROJECT
        test_dirs = self.config.get("test_dirs", ["tests"])
        return skip_reason_no_test_files(test_dirs)

    def run(self, project_root: str) -> CheckResult:
        start_time = time.time()

        # 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",
            )

        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.",
        )
