#!/usr/bin/env python3
"""Print a unified summary of test results.

Reads machine-readable output from test runners and prints a combined
pass/fail table. Designed to run after suites complete (even if one failed),
giving developers a single glance at the full picture.

pytest results come from JUnit XML (--junitxml flag).


Optionally includes coverage statistics when ``--coverage-file`` is
provided, reading the standard ``coverage.json`` produced by
``pytest --cov-report=json``.  A minimum line-coverage gate
(``--fail-under``, default 80%) causes the script to
exit 1 when coverage drops below the threshold.

Usage:
    # Test summary only:
    python tests/scripts/summarize_tests.py

    # With coverage gate (uses baked-in default of 80%):
    python tests/scripts/summarize_tests.py --coverage-file=coverage.json

    # Override the threshold:
    python tests/scripts/summarize_tests.py \
        --coverage-file=coverage.json --fail-under=90

    # With custom paths:
    python tests/scripts/summarize_tests.py \
        --unit-results results-unit.xml \

        --coverage-file coverage.json

Exit codes:
    0 - All tests passed (or skipped) and coverage above threshold
    1 - Test failures/errors, missing expected suites, or coverage below threshold
    2 - Neither results file was found
"""

from __future__ import annotations

import argparse
import json
import sys
import xml.etree.ElementTree as ET
from dataclasses import dataclass
from pathlib import Path

# =============================================================================
# Data Model
# =============================================================================


@dataclass
class SuiteResult:
    """Aggregated results for one test suite."""

    name: str
    passed: int = 0
    failed: int = 0
    errors: int = 0
    skipped: int = 0

    @property
    def total(self) -> int:
        return self.passed + self.failed + self.errors + self.skipped

    @property
    def ok(self) -> bool:
        return self.failed == 0 and self.errors == 0


# =============================================================================
# Parsers
# =============================================================================


def parse_junit_xml(path: Path) -> SuiteResult | None:
    """Parse pytest JUnit XML into a SuiteResult.

    JUnit XML schema (pytest flavour):
        <testsuites>
          <testsuite name="..." tests="N" errors="E" failures="F" skipped="S">
            <testcase .../>
          </testsuite>
        </testsuites>

    Some pytest versions emit a single <testsuite> root instead of wrapping in
    <testsuites>. We handle both.
    """
    if not path.exists():
        return None

    try:
        tree = ET.parse(path)  # noqa: S314 – trusted local file
    except ET.ParseError as exc:
        print(f"  ⚠ Could not parse {path}: {exc}", file=sys.stderr)
        return None

    root = tree.getroot()

    # Collect all <testsuite> elements regardless of nesting
    suites = root.iter("testsuite") if root.tag == "testsuites" else iter([root])

    total_tests = 0
    total_failures = 0
    total_errors = 0
    total_skipped = 0

    for suite in suites:
        total_tests += int(suite.get("tests", 0))
        total_failures += int(suite.get("failures", 0))
        total_errors += int(suite.get("errors", 0))
        total_skipped += int(suite.get("skipped", 0))

    passed = total_tests - total_failures - total_errors - total_skipped
    return SuiteResult(
        name="Unit (pytest)",
        passed=max(passed, 0),
        failed=total_failures,
        errors=total_errors,
        skipped=total_skipped,
    )


# =============================================================================
# Coverage Integration
# =============================================================================


@dataclass
class CoverageResult:
    """Coverage statistics from a standard coverage.json file."""

    file_count: int
    line_pct: float
    branch_pct: float | None = None


def _parse_coverage_json(coverage_file: Path) -> CoverageResult | None:
    """Read a standard ``coverage.json`` and return aggregate stats.

    The file is generated by ``pytest --cov-report=json`` and follows the
    `coverage.py JSON reporter format
    <https://coverage.readthedocs.io/en/latest/cmd.html#json-reporting>`_.

    Returns ``None`` if the file doesn't exist or can't be parsed.
    """
    if not coverage_file.exists():
        print(
            f"  ⚠ Coverage file not found: {coverage_file}",
            file=sys.stderr,
        )
        return None

    try:
        data = json.loads(coverage_file.read_text(encoding="utf-8"))
    except (json.JSONDecodeError, OSError) as exc:
        print(f"  ⚠ Could not read {coverage_file}: {exc}", file=sys.stderr)
        return None

    totals = data.get("totals", {})
    line_pct = totals.get("percent_covered", 0.0)
    file_count = len(data.get("files", {}))

    # Branch coverage is present when --cov-branch was used
    num_branches = totals.get("num_branches")
    if num_branches and num_branches > 0:
        covered = totals.get("covered_branches", 0)
        branch_pct = (covered / num_branches) * 100.0
    else:
        branch_pct = None

    return CoverageResult(
        file_count=file_count,
        line_pct=line_pct,
        branch_pct=branch_pct,
    )


# =============================================================================
# Rendering
# =============================================================================

# Minimum line-coverage percentage baked in at project generation time.
# Override at runtime with ``--fail-under``.
_DEFAULT_FAIL_UNDER: int = 80

# Box drawing characters for the summary table
_DOUBLE = "═"
_SINGLE = "─"
_PADDING = 2  # extra characters beyond widest content line

# ANSI color codes for terminal output
_GREEN = "\033[32m"
_RED = "\033[31m"
_RESET = "\033[0m"


def _hline(char: str, width: int) -> str:
    return char * width


def _format_row(name: str, passed: int, failed: int, skipped: int, total: int) -> str:
    """Format a single data row with consistent column alignment."""
    return f"  {name:<25} {passed:>7} {failed:>7} {skipped:>8} {total:>6}"


def _render_summary(
    results: list[SuiteResult],
    coverage: CoverageResult | None = None,
    fail_under: int = 0,
) -> None:
    """Print a compact summary table to stdout.

    When *coverage* is provided, a coverage statistics line is appended
    after the totals row.  If *fail_under* > 0 and line coverage is below
    that percentage, the result is marked as failed.
    """
    header = f"  {'Suite':<25} {'Passed':>7} {'Failed':>7} {'Skipped':>8} {'Total':>6}"

    # Pre-compute all rows to determine the dynamic width
    total_passed = 0
    total_failed = 0
    total_skipped = 0
    total_total = 0

    data_rows: list[str] = []
    for r in results:
        failures = r.failed + r.errors
        data_rows.append(_format_row(r.name, r.passed, failures, r.skipped, r.total))
        total_passed += r.passed
        total_failed += failures
        total_skipped += r.skipped
        total_total += r.total

    totals_row = _format_row(
        "TOTAL", total_passed, total_failed, total_skipped, total_total
    )

    # -- Coverage line -------------------------------------------------------
    coverage_lines: list[str] = []
    coverage_ok = True
    if coverage is not None:
        parts = [f"{coverage.file_count} file(s)"]
        parts.append(f"Lines {coverage.line_pct:.1f}%")
        if coverage.branch_pct is not None:
            parts.append(f"Branches {coverage.branch_pct:.1f}%")
        if fail_under > 0:
            coverage_ok = coverage.line_pct >= fail_under
            mark = "✓" if coverage_ok else "✗"
            parts.append(f"(threshold {fail_under}%) {mark}")
        coverage_lines.append(f"  Coverage: {' · '.join(parts)}")

    # -- Result line ---------------------------------------------------------
    tests_ok = total_failed == 0
    all_ok = tests_ok and coverage_ok

    if all_ok:
        result_line = "  Result: ALL PASSED ✓"
        result_colored = f"  Result: {_GREEN}ALL PASSED ✓{_RESET}"
    else:
        fail_parts: list[str] = []
        if not tests_ok:
            fail_parts.append(f"{total_failed} test failure(s)")
        if not coverage_ok:
            assert coverage is not None  # for type narrowing
            fail_parts.append(f"line coverage {coverage.line_pct:.1f}% < {fail_under}%")
        detail = ", ".join(fail_parts)
        result_line = f"  Result: FAILED ✗ — {detail}"
        result_colored = f"  Result: {_RED}FAILED ✗ — {detail}{_RESET}"

    # Width = widest content line + padding for visual breathing room
    # Use plain text (no ANSI codes) for width calculation
    all_lines = [header, totals_row, result_line, *data_rows, *coverage_lines]
    width = max(len(line) for line in all_lines) + _PADDING

    # Print the table
    print()
    print(_hline(_DOUBLE, width))
    print("Test Summary".center(width))
    print(_hline(_DOUBLE, width))
    print(header)
    print(_hline(_SINGLE, width))
    for row in data_rows:
        print(row)
    print(_hline(_SINGLE, width))
    print(totals_row)
    if coverage_lines:
        for line in coverage_lines:
            print(line)
    print(_hline(_DOUBLE, width))
    print(result_colored)
    print(_hline(_DOUBLE, width))
    print()


# =============================================================================
# CLI
# =============================================================================


def _build_parser() -> argparse.ArgumentParser:
    parser = argparse.ArgumentParser(
        description="Unified test summary for project test suites.",
    )
    parser.add_argument(
        "--unit-results",
        type=Path,
        default=Path("results-unit.xml"),
        help="Path to pytest JUnit XML file (default: results-unit.xml)",
    )
    parser.add_argument(
        "--coverage-file",
        type=Path,
        default=None,
        help="Path to coverage.json (from pytest --cov-report=json). "
        "When provided, line/branch coverage stats appear in the summary.",
    )
    parser.add_argument(
        "--fail-under",
        type=int,
        default=_DEFAULT_FAIL_UNDER,
        help="Minimum line coverage percentage to pass (default: "
        f"{_DEFAULT_FAIL_UNDER}%%). Set to 0 to disable the gate.",
    )

    return parser


def main() -> int:
    """Entry point. Returns the process exit code."""
    args = _build_parser().parse_args()

    unit_result = parse_junit_xml(args.unit_results)

    found: list[SuiteResult] = []
    not_found: list[str] = []

    if unit_result is not None:
        found.append(unit_result)
    else:
        not_found.append("Unit (pytest)")

    # Nothing at all — likely a configuration problem
    if not found:
        warning_lines = [
            f"  ⚠ {name}: results file not found — NOT RUN or output missing"
            for name in not_found
        ]
        width = max(len(line) for line in warning_lines) + _PADDING
        print()
        print(_hline(_DOUBLE, width))
        print("Test Summary".center(width))
        print(_hline(_DOUBLE, width))
        for line in warning_lines:
            print(line)
        print(_hline(_DOUBLE, width))
        print()
        return 2

    # Print warnings for missing suites, then the table
    for name in not_found:
        print(f"  ⚠ {name}: results file not found — NOT RUN or output missing")

    # Optional coverage statistics
    coverage_result: CoverageResult | None = None
    if args.coverage_file is not None:
        coverage_result = _parse_coverage_json(args.coverage_file)

    fail_under: int = args.fail_under
    _render_summary(found, coverage=coverage_result, fail_under=fail_under)

    # Exit code: 1 if any test failures, missing suites, or coverage below gate
    has_failures = any(not r.ok for r in found)
    has_missing = len(not_found) > 0
    coverage_below = (
        coverage_result is not None
        and fail_under > 0
        and coverage_result.line_pct < fail_under
    )
    return 1 if (has_failures or has_missing or coverage_below) else 0


if __name__ == "__main__":
    sys.exit(main())
