import os
import warnings
from collections.abc import Generator, Iterable, Mapping, Sequence
from functools import wraps
from inspect import iscoroutinefunction
from pathlib import Path
from typing import Any, ClassVar

import pytest

from pytest_results import _LocalStorage, _RegressionImpl, _RegressionStack
from pytest_results._exc_group import iter_nested_exceptions
from pytest_results._testinfo import get_testinfo
from pytest_results.exceptions import ResultsMismatchError

__all__ = ()


class PytestResultsConfig:
    __slots__ = ("__config",)

    __diff_commands: ClassVar[Mapping[str, str]] = {
        "cursor": "cursor -d -r -w {current} {previous}",
        "pycharm": "pycharm diff {current} {previous}",
        "vscode": "code -d -r -w {current} {previous}",
    }

    def __init__(self, pytest_config: pytest.Config) -> None:
        self.__config = pytest_config

    @property
    def accept_diff(self) -> bool:
        return self.__config.getoption("accept_diff")

    @property
    def diff_command(self) -> str | None:
        if diff := self.__get_option_or_ini("diff"):
            return diff

        if ide := self.__get_option_or_ini("ide"):
            lowercase_ide = ide.lower()

            try:
                return self.__diff_commands[lowercase_ide]
            except KeyError:
                warnings.warn(f"pytest-results doesn't yet support the `{ide}` IDE.")

        return None

    def __get_option_or_ini[T](self, key: str) -> T | None:
        config = self.__config
        return config.getoption(key, default=config.getini(key))


@pytest.hookimpl
def pytest_addoption(parser: pytest.Parser) -> None:
    group = parser.getgroup("pytest-results")

    group.addoption(
        "--accept-diff",
        dest="accept_diff",
        action="store_true",
        help="Parameter for accepting new differences between results.",
        default=False,
    )

    diff_help = "Command line to open an interactive comparison. Example: `code -d -w {current} {previous}`."
    group.addoption(
        "--diff",
        dest="diff",
        metavar="COMMAND_LINE",
        help=diff_help,
        default=None,
    )
    parser.addini(
        "diff",
        type="string",
        help=diff_help,
        default=None,
    )

    ide_help = "The IDE to open for interactive comparison."
    group.addoption(
        "--ide",
        dest="ide",
        metavar="IDE",
        help=ide_help,
        default=None,
    )
    parser.addini(
        "ide",
        type="string",
        help=ide_help,
        default=None,
    )


@pytest.hookimpl
def pytest_collection_modifyitems(items: Iterable[pytest.Item]) -> None:
    for item in items:
        if isinstance(item, pytest.Function):
            __autodetect_result(item)


@pytest.hookimpl(trylast=True, wrapper=True)
def pytest_pyfunc_call(
    pyfuncitem: pytest.Function,
) -> Generator[None, object | None, object | None]:
    __tracebackhide__ = True

    try:
        result = yield

    except ResultsMismatchError as mismatch:
        __on_mismatches((mismatch,), pyfuncitem.config)
        raise

    except ExceptionGroup as exc_group:
        if sub_exc_group := exc_group.subgroup(ResultsMismatchError):
            mismatches = tuple(iter_nested_exceptions(sub_exc_group))
            __on_mismatches(mismatches, pyfuncitem.config)

        raise

    return result


@pytest.fixture(scope="function")
def regression(request: pytest.FixtureRequest, tmp_path: Path) -> _RegressionStack:
    dirname = "__pytest_results__"
    storage = _LocalStorage(request.config.rootpath / dirname, tmp_path / dirname)
    testinfo = get_testinfo(request)
    delegate = _RegressionImpl(testinfo, storage, os.system)
    return _RegressionStack(delegate)


def __autodetect_result(pyfuncitem: pytest.Function) -> pytest.Function:
    function = pyfuncitem.obj

    if iscoroutinefunction(function):

        @wraps(function)
        async def wrapper(*args: Any, **kwargs: Any) -> None:
            result = await function(*args, **kwargs)
            __check_result(result, pyfuncitem)

    else:

        @wraps(function)
        def wrapper(*args: Any, **kwargs: Any) -> None:
            result = function(*args, **kwargs)
            __check_result(result, pyfuncitem)

    pyfuncitem.obj = wrapper
    return pyfuncitem


def __check_result(result: Any, pyfuncitem: pytest.Function) -> None:
    regression_stack = __get_regression_fixture(pyfuncitem)

    if result is not None:
        regression_stack.check(result)

    regression_stack.close()


def __get_regression_fixture(pyfuncitem: pytest.Function) -> _RegressionStack:
    return pyfuncitem._request.getfixturevalue(regression.__name__)


def __on_mismatches(
    mismatches: Sequence[ResultsMismatchError],
    pytest_config: pytest.Config,
) -> None:
    if not mismatches:
        return

    config = PytestResultsConfig(pytest_config)

    if config.accept_diff:
        for mismatch in mismatches:
            mismatch.accept_diff()

        pytest.skip()

    elif command := config.diff_command:
        for mismatch in mismatches:
            mismatch.show_diff(command)
