from __future__ import annotations

from tempfile import mkdtemp, NamedTemporaryFile

import logging
import os

from e3.env import Env
from e3.fs import rm
from e3.os.fs import cd, mv, which

import e3.log

try:
    import pytest
except ImportError as ie:  # defensive code
    # Those packages may be missing. In order to avoid importing tests or
    # coverage packages in release mode, they are deliberately not part of
    # the dependencies. Let the user know that the packages should be installed
    # if he wants to use e3 pytest driver.
    raise ImportError("The e3.pytest plugin requires the pytest package") from ie

import typing

if typing.TYPE_CHECKING:
    from typing import Callable

test_errors = False

# Detect that we're in CI mode, most providers set the $CI environment variable
IN_CI_MODE = "CI" in os.environ

DEFAULT_EXCLUDE_LIST = (
    "all: no cover",
    "if TYPE_CHECKING:",
    "@abstractmethod",
    "# os-specific",
    "defensive code",
    "assert_never()",
)


def require_tool(toolname: str) -> Callable:
    """Require a specific tool to run the test.

    When in "CI" mode, a missing tool generates an error. In other
    modes the test is just skipped.

    :param toolname: name of a tool, e.g. git
    """

    def wrapper(request: pytest.FixtureRequest) -> None:
        if not which(toolname):
            if IN_CI_MODE:
                pytest.fail(f"{toolname} not available")
            else:
                pytest.skip(f"{toolname} not available")

    return pytest.fixture(wrapper)


def pytest_addoption(
    parser: pytest.Parser, pluginmanager: pytest.PytestPluginManager
) -> None:
    group = parser.getgroup("e3")
    group.addoption("--e3", action="store_true", help="Use e3 fixtures and reporting")
    group.addoption("--e3-cov-rewrite", nargs=2, help="Use e3 fixtures and reporting")


@pytest.fixture(autouse=True)
def env_protect(request: pytest.FixtureRequest) -> None:
    """Protection against environment change.

    The fixture is enabled for all tests and does the following:

    * store/restore env between tests
    * create a temporary directory and do a cd to it before each
      test. The directory is automatically removed when test ends
    """
    if request.config.getoption("e3"):
        Env().store()
        tempd = mkdtemp()
        cd(tempd)

        os.environ["TZ"] = "UTC"
        os.environ["E3_ENABLE_FEATURE"] = ""
        os.environ["E3_CONFIG"] = "/dev/null"
        if "E3_HOSTNAME" in os.environ:
            del os.environ["E3_HOSTNAME"]

        # Set environment variables for the default Git branch to
        # ensure that we do not assume that the default branch is
        # named master. The best practice when writing test is
        # to either explicitely name the branch or to get the
        # value with `git branch --show-current`
        os.environ["GIT_CONFIG_COUNT"] = "1"
        os.environ["GIT_CONFIG_KEY_0"] = "init.defaultbranch"
        os.environ["GIT_CONFIG_VALUE_0"] = "default_branch"

        def restore_env() -> None:
            Env().restore()
            rm(tempd, True)

        e3.log.activate(level=logging.DEBUG, e3_debug=True)
        request.addfinalizer(restore_env)


def pytest_configure(config: pytest.Config) -> None:
    if (
        config.getoption("e3")
        and hasattr(config.option, "cov_source")
        and config.getoption("cov_source")
    ):
        # When e3 plugin is activated, the report generated by pytest-cov
        # should be deactivated to avoid duplicating the output. Also,
        # some options set by e3 change the coverage, pytest-cov report is not
        # accurate in that case.
        cov = config.pluginmanager.getplugin("_cov")
        cov.options.cov_report = {}
        cov.options.cov_fail_under = None


@pytest.hookimpl(trylast=True)
def pytest_sessionfinish(session: pytest.Session, exitstatus: int) -> None:
    """Manage the exit code depending on if errors were detected or not."""
    if not session.config.getoption("e3"):
        return

    global test_errors
    if test_errors:
        # Return with an exit code of `3` if we encountered errors (not failures).
        # This is the exit code that corresponds to an "internal error" according to the
        # pytest docs, which is the closest thing to having an actual Python error in
        # test code.
        session.exitstatus = 3

    if hasattr(session.config.option, "cov_source") and session.config.getoption(
        "cov_source"
    ):
        # Load only the coverage package if it was activated using the --cov option
        from coverage import Coverage

        cov_file = str(session.config.rootpath / ".coverage")
        if hasattr(
            session.config.option, "e3_cov_rewrite"
        ) and session.config.getoption("e3_cov_rewrite"):
            origin_dir, new_dir = session.config.getoption("e3_cov_rewrite")
            fix_coverage_paths(origin_dir=origin_dir, new_dir=new_dir, cov_db=cov_file)

        os_name = Env().build.os.name

        cov = Coverage(data_file=cov_file)

        # Exclude all lines matching the DEFAULT_EXCLUDE_LIST
        for regex in DEFAULT_EXCLUDE_LIST:
            cov.exclude(regex)

        # Exclude all <os>-only line (unless that's for this OS)
        for regex in (
            "%s-only" % o
            for o in ("darwin", "linux", "solaris", "windows", "bsd", "aix")
            if o != os_name
        ):
            cov.exclude(regex)

        # Handling of Windows
        if os_name == "windows":
            cov.exclude('if sys.platform != "win32":')
        else:
            cov.exclude('if sys.platform == "win32":')
            cov.exclude("unix: no cover")

        # Exclude no cover for this OS
        cov.exclude(f"{os_name}: no cover")

        cov.load()

        # Read configuration files in <root>/tests/coverage to fix the list
        # of files to omit for OS specific list
        # We're relying on the default coverage configuration for the
        # platform-agnostic list
        omit_files: list[str] = cov.get_option("run:omit") or []  # type: ignore
        coverage_conf_dir = session.config.rootpath / "tests" / "coverage"
        conf_file = coverage_conf_dir / f"omit-files-{os_name}"
        if conf_file.exists():
            with conf_file.open() as f:
                for line in f:
                    omit_files.append(line.rstrip())

        # cov.html_report(directory=str(session.config.rootpath ), omit=omit_files)
        cov.html_report(omit=omit_files)
        cov.report(omit=omit_files, precision=3)
        cov.xml_report()


def fix_coverage_paths(origin_dir: str, new_dir: str, cov_db: str) -> None:
    """Fix coverage paths.

    :param origin_dir: path to the package directory, e.g.
        .tox/py311-cov-xdist/lib/python3.11/site-packages/e3
    :param new_dir: path to the dir that should be visible instead of origin_dir
        e.g. src/
    :param cov_db: path to the .coverage database
    """
    # Only import packages from coverage if needed, the pytest plugin can
    # be used without coverage.
    from coverage.sqldata import CoverageData
    from coverage.files import PathAliases

    paths = PathAliases()
    paths.add(origin_dir, new_dir)

    old_cov_file = NamedTemporaryFile(dir=os.path.dirname(cov_db))
    old_cov_file.close()
    try:
        mv(cov_db, old_cov_file.name)

        old_coverage_data = CoverageData(old_cov_file.name)
        old_coverage_data.read()
        new_coverage_data = CoverageData(cov_db)

        # Before 7.5.3, the map_path argument (of type Callable[[str], str])
        # was named aliases (of type PathAliases). Try to handle the two
        # possible APIs.
        try:
            # noinspection PyArgumentList
            new_coverage_data.update(old_coverage_data, map_path=paths.map)
        except TypeError as te:
            if "got an unexpected keyword argument 'map_path'" in str(te):
                # Try with the old API ...
                # noinspection PyArgumentList
                new_coverage_data.update(old_coverage_data, aliases=paths)  # type: ignore[call-arg]
            else:
                raise te
        new_coverage_data.write()
    finally:
        os.unlink(old_cov_file.name)


@pytest.hookimpl(tryfirst=True, hookwrapper=True)
def pytest_runtest_makereport(item: pytest.Item, call: pytest.CallInfo[None]) -> None:  # type: ignore
    """Generate results file.

    When the variable results_dir is set to an existing directory, the testsuite
    will generate results file in "anod" format.
    """
    global test_errors
    # execute all other hooks to obtain the report object
    outcome = yield
    rep = outcome.get_result()
    results_dir = os.environ.get("RESULTS_DIR")

    if not results_dir or not os.path.isdir(results_dir):
        return

    # we only look at actual test calls, not setup/teardown
    if rep.when == "call":
        outcome = rep.outcome.upper()
        test_name = rep.nodeid.replace("/", ".").replace("::", "--")
        if rep.longreprtext:
            with open(os.path.join(results_dir, f"{test_name}.diff"), "w") as f:
                f.write(rep.longreprtext)

        with open(os.path.join(results_dir, "results"), "a") as f:
            f.write(f"{test_name}:{outcome}\n")
    else:
        # If we detect a failure in an item that is not a "proper" test call, it's most
        # likely an error.
        # For example, this could be a failing assertion or a syntax error in a
        # setup/teardown context.
        if rep.outcome == "failed":
            test_errors = True
