"""pytest-molecule plugin implementation."""

from __future__ import annotations

import importlib.util
import logging
import os
import shlex
import subprocess  # noqa: S404
import sys
import warnings

from importlib.metadata import version
from pathlib import Path

import pytest
import yaml

from ansible_compat.config import ansible_version


# Do not add molecule imports here as it does have side effects due to console
# redirection. We need to do these as lazy as possible.


molecule_spec = importlib.util.find_spec("molecule")
HAS_MOLECULE = molecule_spec is not None


logger = logging.getLogger(__name__)
counter = 0


def molecule_pytest_configure(config):  # type: ignore[no-untyped-def]  # noqa: ANN001, ANN201
    """Pytest hook for loading our specific configuration."""
    interesting_env_vars = [
        "ANSIBLE",
        "MOLECULE",
        "DOCKER",
        "PODMAN",
        "VAGRANT",
        "VIRSH",
        "ZUUL",
    ]

    # Add extra information that may be key for debugging failures
    if hasattr(config, "_metadata"):
        for package in ["molecule"]:
            config._metadata["Packages"][package] = version(  # noqa: SLF001
                package,
            )

        if "Tools" not in config._metadata:  # noqa: SLF001
            config._metadata["Tools"] = {}  # noqa: SLF001
        config._metadata["Tools"]["ansible"] = str(ansible_version())  # noqa: SLF001

        # Adds interesting env vars
        env = ""
        for key, value in sorted(os.environ.items()):
            for var_name in interesting_env_vars:
                if key.startswith(var_name):
                    env += f"{key}={value} "
        config._metadata["env"] = env  # noqa: SLF001

    # We hide DeprecationWarnings thrown by driver loading because these are
    # outside our control and worse: they are displayed even on projects that
    # have no molecule tests at all as pytest_configure() is called during
    # collection, causing spam.
    with warnings.catch_warnings():
        warnings.filterwarnings("ignore", category=DeprecationWarning)

        config.option.molecule = {}
        from molecule.api import drivers

        for driver in map(str, drivers()):
            config.addinivalue_line(
                "markers",
                f"{driver}: mark test to run only when {driver} is available",
            )
            config.option.molecule[driver] = {"available": True}

        config.addinivalue_line(
            "markers",
            "no_driver: mark used for scenarios that do not contain driver info",
        )

        config.addinivalue_line(
            "markers",
            "molecule: mark used by all molecule scenarios",
        )

        # validate selinux availability
        if sys.platform == "linux" and Path("/etc/selinux/config").is_file():
            try:
                import selinux  # noqa: F401 pylint: disable=import-outside-toplevel
            except ImportError:
                logger.exception(
                    "It appears that you are trying to use "
                    "molecule with a Python interpreter that does not have the "
                    "libselinux python bindings installed. These can only be "
                    "installed using your distro package manager and are specific "
                    "to each python version. Common package names: "
                    "libselinux-python python2-libselinux python3-libselinux",
                )
                # we do not re-raise this exception because missing or broken
                # selinux bindings are not guaranteed to fail molecule execution.


class MoleculeFile(pytest.File):
    """Wrapper class for molecule files."""

    def collect(self):  # type: ignore[no-untyped-def]  # noqa: ANN201
        """Test generator."""  # noqa: DOC402
        # pylint: disable=global-statement
        global counter  # noqa: PLW0603
        if hasattr(MoleculeItem, "from_parent"):
            yield MoleculeItem.from_parent(name=f"test{counter}", parent=self)
        else:
            yield MoleculeItem(f"test{counter}", self)
        counter += 1

    def __str__(self) -> str:
        """Return test name string representation."""
        return str(self.path.relative_to(Path.cwd()))


class MoleculeItem(pytest.Item):
    """A molecule test.

    Pytest supports multiple tests per file, molecule only one "test".
    """

    def __init__(self, name, parent) -> None:  # type: ignore[no-untyped-def]  # noqa: ANN001
        """Construct MoleculeItem."""
        self.funcargs = {}  # type: ignore[var-annotated]
        super().__init__(name, parent)

        # Determine molecule scenario
        scenario_molecule_yml = self.path
        data_scenario = self.yaml_loader(scenario_molecule_yml)  # type: ignore[arg-type]
        # check if there is a global molecule config
        try:
            data_global = self.yaml_loader(
                Path(Path.cwd()) / ".config/molecule/config.yml",  # type: ignore[arg-type]
            )
            data = data_global | data_scenario
        except FileNotFoundError:
            data = data_scenario

        # we add the driver as mark
        self.molecule_driver = data.get("driver", {}).get("name", "no_driver")
        self.add_marker(self.molecule_driver)

        # check for known markers and add them
        markers = data.get("markers", [])
        if "xfail" in markers:
            self.add_marker(
                pytest.mark.xfail(
                    reason="Marked as broken by scenario configuration.",
                ),
            )
        if "skip" in markers:
            self.add_marker(
                pytest.mark.skip(reason="Disabled by scenario configuration."),
            )

        # we also add platforms as marks
        for platform in data.get("platforms", []):
            platform_name = platform["name"]
            self.config.addinivalue_line(
                "markers",
                f"{platform_name}: molecule platform name is {platform_name}",
            )
            self.add_marker(platform_name)
        self.add_marker("molecule")
        if (
            self.config.option.molecule_unavailable_driver
            and not self.config.option.molecule[self.molecule_driver]["available"]
        ):
            self.add_marker(self.config.option.molecule_unavailable_driver)

    def yaml_loader(self, filepath: str) -> dict:  # type: ignore[type-arg]
        """Load a yaml file at a given filepath.

        Returns:
            dict
        """
        with Path.open(filepath, encoding="utf-8") as file_descriptor:  # type: ignore[call-overload]
            return yaml.safe_load(file_descriptor) or {}

    def runtest(self):  # type: ignore[no-untyped-def]  # noqa: ANN201
        """Perform effective test run."""
        folder = self.path.parent
        folders = folder.parts
        cwd = (Path(folder) / "../..").resolve()
        scenario = folders[-1]

        cmd = [sys.executable, "-m", "molecule"]
        if self.config.option.molecule_base_config:
            cmd.extend(("--base-config", self.config.option.molecule_base_config))
        if self.config.option.skip_no_git_change:
            try:
                with subprocess.Popen(  # noqa: S603
                    [  # noqa: S607
                        "git",
                        "diff",
                        self.config.option.skip_no_git_change,
                        "--",
                        "./",
                    ],
                    cwd=cwd,
                    stdout=subprocess.PIPE,
                    stderr=subprocess.STDOUT,
                    universal_newlines=True,
                ) as proc:
                    proc.wait()
                    if len(proc.stdout.readlines()) == 0:  # type: ignore[union-attr]
                        pytest.skip("No change in role")
            except subprocess.CalledProcessError as exc:
                pytest.fail(
                    "Error checking git diff. Error code was: "
                    + str(exc.returncode)
                    + "\nError output was: "
                    + exc.output,
                )

        cmd.extend(("test", "-s", scenario))
        # We append the additional options to molecule call, allowing user to
        # control how molecule is called by pytest-molecule
        opts = os.environ.get("MOLECULE_OPTS")
        if opts:
            cmd.extend(shlex.split(opts))

        if self.config.getoption("--molecule"):  # Check if --molecule option is enabled
            try:
                # Workaround for STDOUT/STDERR line ordering issue:
                # https://github.com/pytest-dev/pytest/issues/5449
                with subprocess.Popen(  # noqa: S603
                    cmd,
                    cwd=cwd,
                    stdout=subprocess.PIPE,
                    stderr=subprocess.STDOUT,
                    universal_newlines=True,
                ) as proc:
                    for line in proc.stdout:  # type: ignore[union-attr]
                        print(line, end="")  # noqa: T201
                    proc.wait()
                    if proc.returncode != 0:
                        pytest.fail(
                            f"Error code {proc.returncode} returned by: {' '.join(cmd)}",
                            pytrace=False,
                        )
            except subprocess.CalledProcessError as exc:
                pytest.fail(
                    f"Exception {exc} returned by: {' '.join(cmd)}",
                    pytrace=False,
                )
        else:
            pytest.skip(
                "Molecule tests are disabled",
            )  # Skip the test if --molecule option is not enabled

    def reportinfo(self):  # type: ignore[no-untyped-def]  # noqa: ANN201
        """Return representation of test location when in verbose mode."""
        return self.fspath, 0, f"use_case: {self.name}"

    def __str__(self) -> str:
        """Return name of the test."""
        return f"{self.name}[{self.molecule_driver}]"


class MoleculeExceptionError(Exception):
    """Custom exception for error reporting."""


class MoleculeScenario:
    """Molecule subprocess wrapper."""

    def __init__(self, name: str, parent_directory: Path, test_id: str) -> None:
        """Initialize the MoleculeScenario class.

        :param molecule_parent: The parent directory of 'molecule'
        :param scenario_name: The name of the molecule scenario
        :param test_id: The test id
        """
        self.parent_directory = parent_directory
        self.name = name
        self.test_id = test_id

    def test(self) -> subprocess.CompletedProcess:  # type: ignore[type-arg]
        """Run molecule test for the scenario.

        Returns:
            The completed process
        """
        args = [sys.executable, "-m", "molecule", "test", "-s", self.name]

        # We append the additional options to molecule call, allowing user to
        # control how molecule is called by pytest-molecule
        opts = os.environ.get("MOLECULE_OPTS")
        if opts:
            args.extend(shlex.split(opts))

        return subprocess.run(
            args=args,
            capture_output=False,
            check=False,
            cwd=self.parent_directory,
            shell=False,
            text=True,
        )
