"""JUnit XML test result parsing."""

from __future__ import annotations

import logging
import os
import re
from typing import TYPE_CHECKING

from ..test_discovery.linking import module_name_from_file_path
from ._path_resolution import (
    extract_parameterized_test_index,
    file_path_from_module_name,
    resolve_test_file_from_class_path,
)
from .models import FunctionTestInvocation, InvocationId, TestResults

if TYPE_CHECKING:
    import subprocess
    from pathlib import Path

    from ..test_discovery.models import TestType
    from .models import TestConfig, TestFiles

log = logging.getLogger(__name__)

# -- stdout marker regexes used by parse_test_xml --

matches_re_start = re.compile(
    r"!\$######([^:]*)"  # group 1: module path
    r":((?:[^:.]*\.)*)"  # group 2: class prefix
    r"([^.:]*)"  # group 3: test function name
    r":([^:]*)"  # group 4: function being tested
    r":([^:]*)"  # group 5: loop index
    r":([^#]*)"  # group 6: iteration id
    r"######\$!\n"
)

matches_re_end = re.compile(
    r"!######([^:]*)"  # group 1: module path
    r":((?:[^:.]*\.)*)"  # group 2: class prefix
    r"([^.:]*)"  # group 3: test function name
    r":([^:]*)"  # group 4: function being tested
    r":([^:]*)"  # group 5: loop index
    r":([^#]*)"  # group 6: iteration_id or id:runtime
    r"######!"
)


def _parse_func(file_path):  # type: ignore[no-untyped-def]
    """XML parser with huge_tree and recover to handle large/malformed JUnit XML."""
    from lxml.etree import (  # type: ignore[import-untyped]  # noqa: PLC0415
        XMLParser,
        parse,
    )

    xml_parser = XMLParser(huge_tree=True, recover=True)
    return parse(file_path, xml_parser)


def parse_test_xml(  # noqa: C901, PLR0912, PLR0915
    test_xml_file_path: Path,
    test_files: TestFiles,
    test_config: TestConfig,
    run_result: subprocess.CompletedProcess[str] | None = None,
) -> TestResults:
    """Parse JUnit XML test results produced by pytest."""
    from junitparser.xunit2 import JUnitXml  # noqa: PLC0415

    test_results = TestResults()
    if not test_xml_file_path.exists():
        log.warning(
            "No test results for %s found.",
            test_xml_file_path,
        )
        return test_results
    try:
        xml = JUnitXml.fromfile(
            str(test_xml_file_path), parse_func=_parse_func
        )
    except Exception:  # noqa: BLE001
        log.warning(
            "Failed to parse %s as JUnitXml.",
            test_xml_file_path,
            exc_info=True,
        )
        return test_results
    base_dir = test_config.tests_project_rootdir

    for suite in xml:
        for testcase in suite:
            class_name = testcase.classname
            test_file_name = (
                suite._elem.attrib.get("file")  # noqa: SLF001
            )
            log.debug(
                "XML parse: suite_file=%r, classname=%r, testcase=%r, tc_attribs=%s",
                test_file_name,
                class_name,
                testcase.name,
                dict(testcase._elem.attrib),  # noqa: SLF001
            )

            # Skip unittest loader failures
            if (
                test_file_name == f"unittest{os.sep}loader.py"
                and class_name == "unittest.loader._FailedTest"
                and suite.errors == 1
                and suite.tests == 1
            ):
                log.info("Test failed to load, skipping.")
                if run_result is not None:
                    if isinstance(run_result.stdout, str) and isinstance(
                        run_result.stderr, str
                    ):
                        log.info(
                            "Test log - STDOUT: %s \n STDERR: %s",
                            run_result.stdout,
                            run_result.stderr,
                        )
                    else:
                        log.info(
                            "Test log - STDOUT: %s \n STDERR: %s",
                            run_result.stdout.decode(),
                            run_result.stderr.decode(),
                        )
                return test_results

            test_class_path = testcase.classname
            if test_class_path and test_class_path.split(".")[0] in (
                "pytest",
                "_pytest",
            ):
                continue

            try:
                if testcase.name is None:
                    continue
                test_function = (
                    testcase.name.split("[", 1)[0]
                    if "[" in testcase.name
                    else testcase.name
                )
            except (AttributeError, TypeError):
                log.exception(
                    "Error accessing testcase.name in %s",
                    test_xml_file_path,
                )
                continue

            if test_file_name is None:
                if test_class_path:
                    test_file_path = resolve_test_file_from_class_path(
                        test_class_path, base_dir
                    )
                    if test_file_path is None:
                        candidate = file_path_from_module_name(
                            test_class_path, base_dir
                        )
                        log.debug(
                            "resolve_test_file_from_class_path returned None for %s, "
                            "direct path %s exists=%s",
                            test_class_path,
                            candidate,
                            candidate.exists(),
                        )
                        log.warning(
                            "Could not find test file for %s",
                            test_class_path,
                        )
                        continue
                else:
                    test_file_path = file_path_from_module_name(
                        test_function, base_dir
                    )
            else:
                test_file_path = base_dir / test_file_name

            if not test_file_path.exists():
                log.warning(
                    "Test file not found: %s",
                    test_file_path,
                )
                continue

            test_type = test_files.get_test_type_by_instrumented_file_path(
                test_file_path,
            )
            if test_type is None:
                test_type = test_files.get_test_type_by_original_file_path(
                    test_file_path,
                )
            if test_type is None:
                log.debug(
                    "Test type not found for %s (resolved from classname=%s). "
                    "Known instrumented: %s, known original: %s",
                    test_file_path,
                    class_name,
                    [
                        str(tf.instrumented_behavior_file_path)
                        for tf in test_files.test_files
                    ],
                    [
                        str(tf.original_file_path)
                        for tf in test_files.test_files
                    ],
                )
                log.warning(
                    "Test type not found for %s, skipping.",
                    test_file_path,
                )
                continue

            test_module_path = module_name_from_file_path(
                test_file_path,
                test_config.tests_project_rootdir,
            )
            result = testcase.is_passed
            test_class = None
            if class_name is not None and class_name.startswith(
                test_module_path
            ):
                test_class = class_name[len(test_module_path) + 1 :]

            loop_index = (
                extract_parameterized_test_index(testcase.name)
                if testcase.name and "[" in testcase.name
                else 1
            )

            timed_out = False
            if len(testcase.result) > 1:
                log.debug(
                    "Multiple results for %s in %s",
                    testcase.name or "<None>",
                    test_xml_file_path,
                )
            if len(testcase.result) == 1:
                message = (testcase.result[0].message or "").lower()
                if "failed: timeout >" in message or "timed out" in message:
                    timed_out = True

            sys_stdout = testcase.system_out or ""

            begin_matches = list(
                matches_re_start.finditer(sys_stdout),
            )
            end_matches: dict[tuple[str, ...], re.Match[str]] = {}
            for match in matches_re_end.finditer(
                sys_stdout,
            ):
                groups = match.groups()
                if len(groups[5].split(":")) > 1:
                    iteration_id = groups[5].split(":")[0]
                    groups = (*groups[:5], iteration_id)
                end_matches[groups] = match

            if not begin_matches:
                test_results.add(
                    FunctionTestInvocation(
                        loop_index=loop_index,
                        id=InvocationId(
                            test_module_path=(test_module_path),
                            test_class_name=test_class,
                            test_function_name=(test_function),
                            function_getting_tested="",
                            iteration_id="",
                        ),
                        file_name=test_file_path,
                        runtime=None,
                        test_framework=(test_config.test_framework),
                        did_pass=result,
                        test_type=test_type,
                        return_value=None,
                        timed_out=timed_out,
                        stdout="",
                    ),
                )
            else:
                _parse_begin_matches(
                    begin_matches=begin_matches,
                    end_matches=end_matches,
                    sys_stdout=sys_stdout,
                    result=result,
                    test_file_path=test_file_path,
                    test_config=test_config,
                    test_type=test_type,
                    timed_out=timed_out,
                    test_results=test_results,
                )

    if not test_results:
        log.info(
            "Tests '%s' failed to run, skipping.",
            [
                test_file.original_file_path
                for test_file in test_files.test_files
            ],
        )
        if run_result is not None:
            stdout = (
                run_result.stdout
                if isinstance(run_result.stdout, str)
                else run_result.stdout.decode()
            )
            stderr = (
                run_result.stderr
                if isinstance(run_result.stderr, str)
                else run_result.stderr.decode()
            )
            log.debug("Test log - STDOUT: %s \n STDERR: %s", stdout, stderr)
    return test_results


def _parse_begin_matches(  # noqa: PLR0913
    *,
    begin_matches: list[re.Match[str]],
    end_matches: dict[tuple[str, ...], re.Match[str]],
    sys_stdout: str,
    result: bool,
    test_file_path: Path,
    test_config: TestConfig,
    test_type: TestType,
    timed_out: bool,
    test_results: TestResults,
) -> None:
    """Process begin/end marker matches from stdout."""
    for match_index, match in enumerate(begin_matches):
        groups = match.groups()
        runtime = None
        end_match = end_matches.get(groups)
        iteration_id = groups[5]
        if end_match:
            stdout = sys_stdout[match.end() : end_match.start()]
            split_val = end_match.groups()[5].split(":")
            if len(split_val) > 1:
                iteration_id = split_val[0]
                runtime = int(split_val[1])
            else:
                iteration_id, runtime = split_val[0], None
        elif match_index == len(begin_matches) - 1:
            stdout = sys_stdout[match.end() :]
        else:
            stdout = sys_stdout[
                match.end() : begin_matches[match_index + 1].start()
            ]

        test_results.add(
            FunctionTestInvocation(
                loop_index=int(groups[4]),
                id=InvocationId(
                    test_module_path=groups[0],
                    test_class_name=(
                        None if groups[1] == "" else groups[1][:-1]
                    ),
                    test_function_name=groups[2],
                    function_getting_tested=groups[3],
                    iteration_id=iteration_id,
                ),
                file_name=test_file_path,
                runtime=runtime,
                test_framework=test_config.test_framework,
                did_pass=result,
                test_type=test_type,
                return_value=None,
                timed_out=timed_out,
                stdout=stdout,
            ),
        )
