# SPDX-FileCopyrightText: Freya Bruhin (The Compiler) <mail@qutebrowser.org>
#
# SPDX-License-Identifier: GPL-3.0-or-later

"""Test qutebrowser.qt.machinery."""

import re
import sys
import html
import argparse
import typing
from typing import Any, Optional, Union
import dataclasses

import pytest

from qutebrowser.qt import machinery


# All global variables in machinery.py
MACHINERY_VARS = {
    "USE_PYQT5",
    "USE_PYQT6",
    "USE_PYSIDE6",
    "IS_QT5",
    "IS_QT6",
    "IS_PYQT",
    "IS_PYSIDE",
    "INFO",
}
# Make sure we didn't forget anything that's declared in the module.
# Not sure if this is a good idea. Might remove it in the future if it breaks.
assert set(typing.get_type_hints(machinery).keys()) == MACHINERY_VARS


@pytest.fixture(autouse=True)
def undo_init(monkeypatch: pytest.MonkeyPatch) -> None:
    """Pretend Qt support isn't initialized yet and Qt was never imported."""
    monkeypatch.setattr(machinery, "_initialized", False)
    monkeypatch.delenv("QUTE_QT_WRAPPER", raising=False)
    for wrapper in machinery.WRAPPERS:
        monkeypatch.delitem(sys.modules, wrapper, raising=False)
    for var in MACHINERY_VARS:
        monkeypatch.delattr(machinery, var)


@pytest.mark.parametrize(
    "exception, base",
    [
        (machinery.Unavailable(), ModuleNotFoundError),
        (machinery.NoWrapperAvailableError(machinery.SelectionInfo()), ImportError),
    ],
)
def test_importerror_exceptions(exception: Exception, base: type[Exception]):
    with pytest.raises(base):
        raise exception


def test_selectioninfo_set_module_error():
    info = machinery.SelectionInfo()
    info.set_module_error("PyQt5", ImportError("Python imploded"))
    assert info == machinery.SelectionInfo(
        wrapper=None,
        reason=machinery.SelectionReason.unknown,
        outcomes={"PyQt5": "ImportError: Python imploded"},
    )


def test_selectioninfo_use_wrapper():
    info = machinery.SelectionInfo()
    info.use_wrapper("PyQt6")
    assert info == machinery.SelectionInfo(
        wrapper="PyQt6",
        reason=machinery.SelectionReason.unknown,
        outcomes={"PyQt6": "success"},
    )


@pytest.mark.parametrize(
    "info, expected",
    [
        (
            machinery.SelectionInfo(
                wrapper="PyQt5",
                reason=machinery.SelectionReason.cli,
            ),
            "Qt wrapper: PyQt5 (via --qt-wrapper)",
        ),
        (
            machinery.SelectionInfo(
                wrapper="PyQt6",
                reason=machinery.SelectionReason.env,
            ),
            "Qt wrapper: PyQt6 (via QUTE_QT_WRAPPER)",
        ),
        (
            machinery.SelectionInfo(
                wrapper="PyQt6",
                reason=machinery.SelectionReason.auto,
                outcomes={
                    "PyQt6": "success",
                    "PyQt5": "ImportError: Python imploded",
                },
            ),
            (
                "Qt wrapper info:\n"
                "  PyQt6: success\n"
                "  PyQt5: ImportError: Python imploded\n"
                "  -> selected: PyQt6 (via autoselect)"
            ),
        ),
    ],
)
def test_selectioninfo_str(info: machinery.SelectionInfo, expected: str):
    assert str(info) == expected
    # The test is somewhat duplicating the logic here, but it's a good sanity check.
    assert info.to_html() == html.escape(expected).replace("\n", "<br>")


@pytest.mark.parametrize("order", [["PyQt5", "PyQt6"], ["PyQt6", "PyQt5"]])
def test_selectioninfo_str_wrapper_precedence(order: list[str]):
    """The order of the wrappers should be the same as in machinery.WRAPPERS."""
    info = machinery.SelectionInfo(
        wrapper="PyQt6",
        reason=machinery.SelectionReason.auto,
    )
    for module in order:
        info.set_module_error(module, ImportError("Python imploded"))

    lines = str(info).splitlines()[1:-1]
    wrappers = [line.split(":")[0].strip() for line in lines]
    assert wrappers == machinery.WRAPPERS


@pytest.fixture
def modules():
    """Return a dict of modules to import-patch, all unavailable by default."""
    return dict.fromkeys(machinery.WRAPPERS, False)


@pytest.mark.parametrize(
    "available, expected",
    [
        pytest.param(
            {
                "PyQt5": ModuleNotFoundError("hiding somewhere"),
                "PyQt6": ModuleNotFoundError("hiding somewhere"),
            },
            machinery.SelectionInfo(
                wrapper=None,
                reason=machinery.SelectionReason.auto,
                outcomes={
                    "PyQt5": "ModuleNotFoundError: hiding somewhere",
                    "PyQt6": "ModuleNotFoundError: hiding somewhere",
                },
            ),
            id="none-available",
        ),
        pytest.param(
            {
                "PyQt5": ModuleNotFoundError("hiding somewhere"),
                "PyQt6": True,
            },
            machinery.SelectionInfo(
                wrapper="PyQt6",
                reason=machinery.SelectionReason.auto,
                outcomes={"PyQt6": "success"},
            ),
            id="only-pyqt6",
        ),
        pytest.param(
            {
                "PyQt5": True,
                "PyQt6": ModuleNotFoundError("hiding somewhere"),
            },
            machinery.SelectionInfo(
                wrapper="PyQt5",
                reason=machinery.SelectionReason.auto,
                outcomes={
                    "PyQt6": "ModuleNotFoundError: hiding somewhere",
                    "PyQt5": "success",
                },
            ),
            id="only-pyqt5",
        ),
        pytest.param(
            {"PyQt5": True, "PyQt6": True},
            machinery.SelectionInfo(
                wrapper="PyQt6",
                reason=machinery.SelectionReason.auto,
                outcomes={"PyQt6": "success"},
            ),
            id="both",
        ),
        pytest.param(
            {
                "PyQt6": ImportError("Fake ImportError for PyQt6."),
                "PyQt5": True,
            },
            machinery.SelectionInfo(
                wrapper=None,
                reason=machinery.SelectionReason.auto,
                outcomes={
                    "PyQt6": "ImportError: Fake ImportError for PyQt6.",
                },
            ),
            id="import-error",
        ),
    ],
)
def test_autoselect(
    stubs: Any,
    available: dict[str, Union[bool, Exception]],
    expected: machinery.SelectionInfo,
    monkeypatch: pytest.MonkeyPatch,
):
    stubs.ImportFake(available, monkeypatch).patch()
    assert machinery._autoselect_wrapper() == expected


@dataclasses.dataclass
class SelectWrapperCase:
    name: str
    expected: machinery.SelectionInfo
    args: Optional[argparse.Namespace] = None
    env: Optional[str] = None
    override: Optional[str] = None

    def __str__(self):
        return self.name


class TestSelectWrapper:
    @pytest.mark.parametrize(
        "tc",
        [
            # Only argument given
            SelectWrapperCase(
                "pyqt6-arg",
                args=argparse.Namespace(qt_wrapper="PyQt6"),
                expected=machinery.SelectionInfo(
                    wrapper="PyQt6", reason=machinery.SelectionReason.cli
                ),
            ),
            SelectWrapperCase(
                "pyqt5-arg",
                args=argparse.Namespace(qt_wrapper="PyQt5"),
                expected=machinery.SelectionInfo(
                    wrapper="PyQt5", reason=machinery.SelectionReason.cli
                ),
            ),
            SelectWrapperCase(
                "pyqt6-arg-empty-env",
                args=argparse.Namespace(qt_wrapper="PyQt5"),
                env="",
                expected=machinery.SelectionInfo(
                    wrapper="PyQt5", reason=machinery.SelectionReason.cli
                ),
            ),
            # Only environment variable given
            SelectWrapperCase(
                "pyqt6-env",
                env="PyQt6",
                expected=machinery.SelectionInfo(
                    wrapper="PyQt6", reason=machinery.SelectionReason.env
                ),
            ),
            SelectWrapperCase(
                "pyqt5-env",
                env="PyQt5",
                expected=machinery.SelectionInfo(
                    wrapper="PyQt5", reason=machinery.SelectionReason.env
                ),
            ),
            # Both given
            SelectWrapperCase(
                "pyqt5-arg-pyqt6-env",
                args=argparse.Namespace(qt_wrapper="PyQt5"),
                env="PyQt6",
                expected=machinery.SelectionInfo(
                    wrapper="PyQt5", reason=machinery.SelectionReason.cli
                ),
            ),
            SelectWrapperCase(
                "pyqt6-arg-pyqt5-env",
                args=argparse.Namespace(qt_wrapper="PyQt6"),
                env="PyQt5",
                expected=machinery.SelectionInfo(
                    wrapper="PyQt6", reason=machinery.SelectionReason.cli
                ),
            ),
            SelectWrapperCase(
                "pyqt6-arg-pyqt6-env",
                args=argparse.Namespace(qt_wrapper="PyQt6"),
                env="PyQt6",
                expected=machinery.SelectionInfo(
                    wrapper="PyQt6", reason=machinery.SelectionReason.cli
                ),
            ),
            # Override
            SelectWrapperCase(
                "override-only",
                override="PyQt6",
                expected=machinery.SelectionInfo(
                    wrapper="PyQt6", reason=machinery.SelectionReason.override
                ),
            ),
            SelectWrapperCase(
                "override-arg",
                args=argparse.Namespace(qt_wrapper="PyQt5"),
                override="PyQt6",
                expected=machinery.SelectionInfo(
                    wrapper="PyQt5", reason=machinery.SelectionReason.cli
                ),
            ),
            SelectWrapperCase(
                "override-env",
                env="PyQt5",
                override="PyQt6",
                expected=machinery.SelectionInfo(
                    wrapper="PyQt5", reason=machinery.SelectionReason.env
                ),
            ),
        ],
        ids=str,
    )
    def test_select(self, tc: SelectWrapperCase, monkeypatch: pytest.MonkeyPatch):
        if tc.env is None:
            monkeypatch.delenv("QUTE_QT_WRAPPER", raising=False)
        else:
            monkeypatch.setenv("QUTE_QT_WRAPPER", tc.env)

        if tc.override is not None:
            monkeypatch.setattr(machinery, "_WRAPPER_OVERRIDE", tc.override)

        assert machinery._select_wrapper(tc.args) == tc.expected

    @pytest.mark.parametrize(
        "args, env",
        [
            (None, None),
            (argparse.Namespace(qt_wrapper=None), None),
            (argparse.Namespace(qt_wrapper=None), ""),
        ],
    )
    def test_autoselect_by_default(
        self,
        args: Optional[argparse.Namespace],
        env: Optional[str],
        monkeypatch: pytest.MonkeyPatch,
    ):
        """Test that the default behavior is to autoselect a wrapper.

        Autoselection itself is tested further down.
        """
        if env is None:
            monkeypatch.delenv("QUTE_QT_WRAPPER", raising=False)
        else:
            monkeypatch.setenv("QUTE_QT_WRAPPER", env)

        assert machinery._select_wrapper(args).reason == machinery.SelectionReason.auto

    def test_after_qt_import(self, monkeypatch: pytest.MonkeyPatch):
        monkeypatch.setitem(sys.modules, "PyQt6", None)
        with pytest.warns(UserWarning, match="PyQt6 already imported"):
            machinery._select_wrapper(args=None)

    def test_invalid_override(self, monkeypatch: pytest.MonkeyPatch):
        monkeypatch.setattr(machinery, "_WRAPPER_OVERRIDE", "invalid")
        with pytest.raises(AssertionError):
            machinery._select_wrapper(args=None)


class TestInit:
    @pytest.fixture
    def empty_args(self) -> argparse.Namespace:
        return argparse.Namespace(qt_wrapper=None)

    def test_multiple_implicit(self, monkeypatch: pytest.MonkeyPatch):
        monkeypatch.setattr(machinery, "_initialized", True)
        machinery.init_implicit()
        machinery.init_implicit()

    def test_multiple_explicit(
        self,
        monkeypatch: pytest.MonkeyPatch,
        empty_args: argparse.Namespace,
    ):
        monkeypatch.setattr(machinery, "_initialized", True)

        with pytest.raises(
            machinery.Error, match=r"init\(\) already called before application init"
        ):
            machinery.init(args=empty_args)

    @pytest.fixture(params=["auto", "", None])
    def qt_auto_env(
        self,
        request: pytest.FixtureRequest,
        monkeypatch: pytest.MonkeyPatch,
    ):
        """Trigger wrapper autoselection via environment variable.

        Autoselection should be used in three scenarios:

        - The environment variable is set to "auto".
        - The environment variable is set to an empty string.
        - The environment variable is not set at all.

        We run test_none_available_*() for all three scenarios.
        """
        if request.param is None:
            monkeypatch.delenv("QUTE_QT_WRAPPER", raising=False)
        else:
            monkeypatch.setenv("QUTE_QT_WRAPPER", request.param)

    def test_none_available_implicit(
        self,
        stubs: Any,
        modules: dict[str, bool],
        monkeypatch: pytest.MonkeyPatch,
        qt_auto_env: None,
    ):
        stubs.ImportFake(modules, monkeypatch).patch()

        message_lines = [
            "No Qt wrapper was importable.",
            "",
            "Qt wrapper info:",
            "  PyQt6: ImportError: Fake ImportError for PyQt6.",
            "  PyQt5: not imported",
            "  -> selected: None (via autoselect)",
        ]

        with pytest.raises(
            machinery.NoWrapperAvailableError,
            match=re.escape("\n".join(message_lines)),
        ):
            machinery.init_implicit()

    def test_none_available_explicit(
        self,
        stubs: Any,
        modules: dict[str, bool],
        monkeypatch: pytest.MonkeyPatch,
        empty_args: argparse.Namespace,
        qt_auto_env: None,
    ):
        stubs.ImportFake(modules, monkeypatch).patch()

        info = machinery.init(args=empty_args)
        assert info == machinery.SelectionInfo(
            wrapper=None,
            reason=machinery.SelectionReason.auto,
            outcomes={
                "PyQt6": "ImportError: Fake ImportError for PyQt6.",
            },
        )

    @pytest.mark.parametrize(
        "selected_wrapper, true_vars",
        [
            ("PyQt6", ["USE_PYQT6", "IS_QT6", "IS_PYQT"]),
            ("PyQt5", ["USE_PYQT5", "IS_QT5", "IS_PYQT"]),
            ("PySide6", ["USE_PYSIDE6", "IS_QT6", "IS_PYSIDE"]),
        ],
    )
    @pytest.mark.parametrize("explicit", [True, False])
    def test_properly(
        self,
        monkeypatch: pytest.MonkeyPatch,
        selected_wrapper: str,
        true_vars: str,
        explicit: bool,
        empty_args: argparse.Namespace,
    ):
        info = machinery.SelectionInfo(
            wrapper=selected_wrapper,
            reason=machinery.SelectionReason.fake,
        )
        monkeypatch.setattr(machinery, "_select_wrapper", lambda args: info)

        if explicit:
            ret = machinery.init(empty_args)
            assert ret == info
        else:
            machinery.init_implicit()

        assert machinery.INFO == info

        bool_vars = MACHINERY_VARS - {"INFO"}
        expected_vars = dict.fromkeys(bool_vars, False)
        expected_vars.update(dict.fromkeys(true_vars, True))
        actual_vars = {var: getattr(machinery, var) for var in bool_vars}

        assert expected_vars == actual_vars
