#!/usr/bin/env python3
"""Run `python -m unittest discover` with its usual output, and also write a JUnit XML report.

    bin/unittest-junit <report.xml> [discover args ...]

Stdlib only, like tangier itself. The gate's `junit` field reads the report,
so `gate stats` can name the tests that fail. The exit code is unittest's.
"""

import inspect
import os
import sys
import time
import unittest
import xml.etree.ElementTree as ET


class JUnitResult(unittest.TextTestResult):
    """A text result that also keeps each test's outcome, for the report."""

    def __init__(self, *args, **kwargs) -> None:
        super().__init__(*args, **kwargs)
        # Each test's id, mapped to its testcase element. A test with failing subtests is one testcase.
        self.cases: dict[str, ET.Element] = {}
        self.started: dict[str, float] = {}

    def startTest(self, test: unittest.TestCase) -> None:
        self.started[test.id()] = time.perf_counter()
        super().startTest(test)

    def stopTest(self, test: unittest.TestCase) -> None:
        case = self._case(test)
        case.set("time", f"{time.perf_counter() - self.started.get(test.id(), time.perf_counter()):.3f}")
        super().stopTest(test)

    def _case(self, test: unittest.TestCase) -> ET.Element:
        if test.id() not in self.cases:
            case = ET.Element("testcase", classname=_classname(test), name=_name(test))
            where = _where(test)
            if where:
                case.set("file", where[0])
                case.set("line", str(where[1]))
            self.cases[test.id()] = case
        return self.cases[test.id()]

    def _outcome(self, test: unittest.TestCase, tag: str, err=None, message: str = "") -> None:
        case = self._case(test)
        if case.find("failure") is not None or case.find("error") is not None:
            return
        element = ET.SubElement(case, tag)
        if err is not None:
            element.set("type", err[0].__name__)
            element.set("message", str(err[1]).splitlines()[0] if str(err[1]) else err[0].__name__)
            element.text = self._exc_info_to_string(err, test)
        elif message:
            element.set("message", message)

    def addFailure(self, test, err) -> None:
        super().addFailure(test, err)
        self._outcome(test, "failure", err)

    def addError(self, test, err) -> None:
        super().addError(test, err)
        self._outcome(test, "error", err)

    def addSubTest(self, test, subtest, err) -> None:
        super().addSubTest(test, subtest, err)
        if err is not None:
            failed = issubclass(err[0], test.failureException)
            self._outcome(test, "failure" if failed else "error", err)

    def addSkip(self, test, reason) -> None:
        super().addSkip(test, reason)
        self._outcome(test, "skipped", message=reason)

    def addUnexpectedSuccess(self, test) -> None:
        super().addUnexpectedSuccess(test)
        self._outcome(test, "failure", message="unexpected success")

    def addSuccess(self, test) -> None:
        super().addSuccess(test)
        _ = self._case(test)

    def addExpectedFailure(self, test, err) -> None:
        super().addExpectedFailure(test, err)
        _ = self._case(test)


class JUnitRunner(unittest.TextTestRunner):
    resultclass = JUnitResult


def _classname(test: unittest.TestCase) -> str:
    """`tangier.tests.test_gate.TestRun`, or for an import or fixture error, what unittest names it."""
    if hasattr(test, "_testMethodName") and type(test).__name__ != "_FailedTest":
        return f"{type(test).__module__}.{type(test).__qualname__}"
    return test.id().rpartition(".")[0] or test.id()


def _name(test: unittest.TestCase) -> str:
    if hasattr(test, "_testMethodName") and type(test).__name__ != "_FailedTest":
        return test._testMethodName
    return test.id().rpartition(".")[2] or str(test)


def _where(test: unittest.TestCase) -> tuple[str, int] | None:
    """The test method's file, relative to the working directory, and its line."""
    method = getattr(test, getattr(test, "_testMethodName", ""), None)
    try:
        path = inspect.getsourcefile(method) if method else None
        line = inspect.getsourcelines(method)[1] if method else 0
    except (OSError, TypeError):
        return None
    return (os.path.relpath(path), line) if path else None


def _write(result: JUnitResult, path: str, took: float) -> None:
    cases = list(result.cases.values())
    count = {tag: sum(1 for c in cases if c.find(tag) is not None) for tag in ("failure", "error", "skipped")}
    suite = ET.Element(
        "testsuite",
        name="unittest",
        tests=str(len(cases)),
        failures=str(count["failure"]),
        errors=str(count["error"]),
        skipped=str(count["skipped"]),
        time=f"{took:.3f}",
    )
    suite.extend(cases)
    root = ET.Element("testsuites")
    root.append(suite)
    ET.indent(root)
    os.makedirs(os.path.dirname(path) or ".", exist_ok=True)
    ET.ElementTree(root).write(path, encoding="utf-8", xml_declaration=True)


def main(argv: list[str]) -> int:
    if len(argv) < 2 or argv[1].startswith("-"):
        print(__doc__.strip().splitlines()[2].strip(), file=sys.stderr)
        return 2
    path = argv[1]
    start = time.perf_counter()
    program = unittest.main(
        module=None,
        argv=["unittest-junit", "discover", *argv[2:]],
        # A class, not an instance, so unittest passes it `-v`, `-f`, `--locals` and its default warnings filter.
        testRunner=JUnitRunner,
        exit=False,
    )
    result = program.result
    _write(result, path, time.perf_counter() - start)
    if result.testsRun == 0 and not result.skipped:
        # As `python -m unittest` does: no tests is not a pass.
        return 5
    return 0 if result.wasSuccessful() else 1


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