#!/usr/bin/env python3
"""Run Python tests and emit a SonarQube-compatible Cobertura report.

The runner intentionally uses only the Python standard library so CI images do
not need pip or third-party packages.
"""

from __future__ import annotations

from dataclasses import dataclass
import dis
import inspect
import token
import tokenize
from pathlib import Path
import sys
import time
import trace
import traceback
import types
import unittest
import xml.etree.ElementTree as ET

PROJECT_ROOT = Path(__file__).resolve().parents[1]
SOURCE_ROOT = PROJECT_ROOT / "tools" / "python"
TEST_ROOT = PROJECT_ROOT / "test" / "python"
REPORT_PATH = PROJECT_ROOT / "coverage/python/coverage.xml"


@dataclass(frozen=True)
class FileCoverage:
    """Coverage data for one Python source file."""

    path: Path
    executable_lines: tuple[int, ...]
    hits: dict[int, int]

    @property
    def covered_lines(self) -> int:
        return sum(1 for line in self.executable_lines if self.hits.get(line, 0) > 0)

    @property
    def line_rate(self) -> float:
        if not self.executable_lines:
            return 1.0
        return self.covered_lines / len(self.executable_lines)


def _docstring_lines(path: Path, encoding: str) -> set[int]:
    lines: set[int] = set()
    previous_type = token.INDENT
    with path.open(encoding=encoding) as source_file:
        tokens = tokenize.generate_tokens(source_file.readline)
        for token_type, _token_text, start, end, _source_line in tokens:
            if token_type == token.STRING and previous_type == token.INDENT:
                lines.update(range(start[0], end[0] + 1))
            previous_type = token_type
    return lines


def _collect_code_lines(
    code: types.CodeType,
    docstring_lines: set[int],
    lines: set[int],
) -> None:
    for _, line_number in dis.findlinestarts(code):
        if (
            isinstance(line_number, int)
            and line_number > 0
            and line_number not in docstring_lines
        ):
            lines.add(line_number)
    for constant in code.co_consts:
        if inspect.iscode(constant):
            _collect_code_lines(constant, docstring_lines, lines)


def _executable_lines(path: Path) -> tuple[int, ...]:
    with tokenize.open(path) as source_file:
        source = source_file.read()
        encoding = source_file.encoding
    code = compile(source, str(path), "exec")
    lines: set[int] = set()
    _collect_code_lines(code, _docstring_lines(path, encoding), lines)
    return tuple(sorted(lines))


def _discover_tests() -> unittest.TestSuite:
    return unittest.defaultTestLoader.discover(
        start_dir=str(TEST_ROOT),
        pattern="test_*.py",
    )


def _execute_tests() -> unittest.TestResult:
    suite = _discover_tests()
    return unittest.TextTestRunner(verbosity=1).run(suite)


def _run_tests() -> trace.CoverageResults:
    tracer = trace.Trace(
        count=True,
        trace=False,
        ignoredirs=[sys.prefix, sys.exec_prefix],
    )
    result = tracer.runfunc(_execute_tests)
    if not result.wasSuccessful():
        raise RuntimeError("Mindestens ein Python-Test ist fehlgeschlagen.")
    return tracer.results()


def _normalise_path(filename: str) -> Path:
    return Path(filename).resolve()


def _collect_coverage(results: trace.CoverageResults) -> list[FileCoverage]:
    counts_by_file: dict[Path, dict[int, int]] = {}
    for (filename, line_number), hit_count in results.counts.items():
        resolved = _normalise_path(filename)
        if not resolved.is_relative_to(SOURCE_ROOT):
            continue
        counts_by_file.setdefault(resolved, {})[line_number] = hit_count

    coverage = []
    for path in sorted(SOURCE_ROOT.glob("*.py")):
        coverage.append(
            FileCoverage(
                path=path,
                executable_lines=_executable_lines(path),
                hits=counts_by_file.get(path.resolve(), {}),
            )
        )
    return coverage


def _rate(covered: int, valid: int) -> str:
    if valid == 0:
        return "1"
    return f"{covered / valid:.6f}"


def _write_report(files: list[FileCoverage]) -> None:
    total_valid = sum(len(item.executable_lines) for item in files)
    total_covered = sum(item.covered_lines for item in files)

    root = ET.Element(
        "coverage",
        {
            "version": "stdlib-trace",
            "timestamp": str(int(time.time())),
            "lines-valid": str(total_valid),
            "lines-covered": str(total_covered),
            "line-rate": _rate(total_covered, total_valid),
            "branches-valid": "0",
            "branches-covered": "0",
            "branch-rate": "0",
            "complexity": "0",
        },
    )
    sources = ET.SubElement(root, "sources")
    ET.SubElement(sources, "source").text = str(PROJECT_ROOT)
    packages = ET.SubElement(root, "packages")
    package = ET.SubElement(
        packages,
        "package",
        {
            "name": "tools.python",
            "line-rate": _rate(total_covered, total_valid),
            "branch-rate": "0",
            "complexity": "0",
        },
    )
    classes = ET.SubElement(package, "classes")

    for item in files:
        relative = item.path.relative_to(PROJECT_ROOT).as_posix()
        class_element = ET.SubElement(
            classes,
            "class",
            {
                "name": item.path.stem,
                "filename": relative,
                "line-rate": f"{item.line_rate:.6f}",
                "branch-rate": "0",
                "complexity": "0",
            },
        )
        ET.SubElement(class_element, "methods")
        lines = ET.SubElement(class_element, "lines")
        for line_number in item.executable_lines:
            ET.SubElement(
                lines,
                "line",
                {
                    "number": str(line_number),
                    "hits": str(item.hits.get(line_number, 0)),
                    "branch": "false",
                },
            )

    REPORT_PATH.parent.mkdir(parents=True, exist_ok=True)
    tree = ET.ElementTree(root)
    ET.indent(tree, space="  ")
    tree.write(REPORT_PATH, encoding="utf-8", xml_declaration=True)


def _print_summary(files: list[FileCoverage]) -> None:
    print("Name                              Stmts   Miss  Cover")
    print("-----------------------------------------------------")
    total_valid = 0
    total_covered = 0
    for item in files:
        valid = len(item.executable_lines)
        covered = item.covered_lines
        total_valid += valid
        total_covered += covered
        missing = valid - covered
        relative = item.path.relative_to(PROJECT_ROOT).as_posix()
        print(f"{relative:<33} {valid:>5} {missing:>6} {item.line_rate:>6.0%}")
    print("-----------------------------------------------------")
    print(
        f"TOTAL{'':<28} {total_valid:>5} {total_valid - total_covered:>6} "
        f"{(total_covered / total_valid if total_valid else 1):>6.0%}"
    )
    print(f"Cobertura-Bericht: {REPORT_PATH.relative_to(PROJECT_ROOT)}")


def main() -> int:
    try:
        results = _run_tests()
        files = _collect_coverage(results)
        _write_report(files)
        _print_summary(files)
    except Exception as error:  # noqa: BLE001 - CI output must include unexpected errors.
        print(
            f"Python-Coverage fehlgeschlagen: {type(error).__name__}: {error}",
            file=sys.stderr,
        )
        traceback.print_exc(file=sys.stderr)
        return 1
    return 0


if __name__ == "__main__":
    raise SystemExit(main())
