import os
import pathlib
import platform
import shutil
import time
from typing import List

from pytest import MonkeyPatch, fixture
from pytest_mock import MockerFixture
from pytestqt.qtbot import QtBot

from dangerzone import errors
from dangerzone.document import Document
from dangerzone.gui import MainWindow
from dangerzone.gui import main_window as main_window_module
from dangerzone.gui import updater as updater_module
from dangerzone.gui.logic import DangerzoneGui

# import Pyside related objects from here to avoid duplicating import logic.
from dangerzone.gui.main_window import (
    ContentWidget,
    InstallContainerThread,
    QtCore,
    QtGui,
    WaitingWidgetContainer,
)
from dangerzone.gui.updater import UpdateReport, UpdaterThread
from dangerzone.isolation_provider.container import Container
from dangerzone.isolation_provider.dummy import Dummy

from .test_updater import assert_report_equal, default_updater_settings

##
# Widget Fixtures
##


@fixture
def content_widget(qtbot: QtBot, mocker: MockerFixture) -> ContentWidget:
    # Setup
    mock_app = mocker.MagicMock()
    dummy = mocker.MagicMock()
    dz = DangerzoneGui(mock_app, dummy)
    w = ContentWidget(dz)
    qtbot.addWidget(w)
    return w


def drag_files_event(mocker: MockerFixture, files: List[str]) -> QtGui.QDropEvent:
    ev = mocker.MagicMock(spec=QtGui.QDropEvent)
    ev.accept.return_value = True

    urls = [QtCore.QUrl.fromLocalFile(x) for x in files]
    ev.mimeData.return_value.has_urls.return_value = True
    ev.mimeData.return_value.urls.return_value = urls
    return ev


@fixture
def drag_valid_files_event(
    mocker: MockerFixture, sample_doc: str, sample_pdf: str
) -> QtGui.QDropEvent:
    return drag_files_event(mocker, [sample_doc, sample_pdf])


@fixture
def drag_1_invalid_file_event(
    mocker: MockerFixture, sample_doc: str, tmp_path: pathlib.Path
) -> QtGui.QDropEvent:
    unsupported_file_path = tmp_path / "file.unsupported"
    shutil.copy(sample_doc, unsupported_file_path)
    return drag_files_event(mocker, [str(unsupported_file_path)])


@fixture
def drag_1_invalid_and_2_valid_files_event(
    mocker: MockerFixture, tmp_path: pathlib.Path, sample_doc: str, sample_pdf: str
) -> QtGui.QDropEvent:
    unsupported_file_path = tmp_path / "file.unsupported"
    shutil.copy(sample_doc, unsupported_file_path)
    return drag_files_event(
        mocker, [sample_doc, sample_pdf, str(unsupported_file_path)]
    )


@fixture
def drag_text_event(mocker: MockerFixture) -> QtGui.QDropEvent:
    ev = mocker.MagicMock()
    ev.accept.return_value = True
    ev.mimeData.return_value.has_urls.return_value = False
    return ev


def test_default_menu(
    qtbot: QtBot,
    updater: UpdaterThread,
) -> None:
    """Check that the default menu entries are in order."""
    updater.dangerzone.settings.set("updater_check", True)

    window = MainWindow(updater.dangerzone)
    menu_actions = window.hamburger_button.menu().actions()
    assert len(menu_actions) == 3

    toggle_updates_action = menu_actions[0]
    assert toggle_updates_action.text() == "Check for updates"
    assert toggle_updates_action.isChecked()

    separator = menu_actions[1]
    assert separator.isSeparator()

    exit_action = menu_actions[2]
    assert exit_action.text() == "Exit"

    toggle_updates_action.trigger()
    assert not toggle_updates_action.isChecked()
    assert updater.dangerzone.settings.get("updater_check") is False


def test_no_update(
    qtbot: QtBot,
    updater: UpdaterThread,
    monkeypatch: MonkeyPatch,
    mocker: MockerFixture,
) -> None:
    """Test that when no update has been detected, the user is not alerted."""
    # Check that when no update is detected, e.g., due to update cooldown, an empty
    # report is received that does not affect the menu entries.
    curtime = int(time.time())
    updater.dangerzone.settings.set("updater_check", True)
    updater.dangerzone.settings.set("updater_errors", 9)
    updater.dangerzone.settings.set("updater_last_check", curtime)

    expected_settings = default_updater_settings()
    expected_settings["updater_check"] = True
    expected_settings["updater_errors"] = 0  # errors must be cleared
    expected_settings["updater_last_check"] = curtime

    window = MainWindow(updater.dangerzone)
    window.register_update_handler(updater.finished)
    handle_updates_spy = mocker.spy(window, "handle_updates")

    menu_actions_before = window.hamburger_button.menu().actions()

    with qtbot.waitSignal(updater.finished):
        updater.start()

    # Check that the callback function gets an empty report.
    handle_updates_spy.assert_called_once()
    assert_report_equal(handle_updates_spy.call_args.args[0], UpdateReport())

    # Check that the menu entries remain exactly the same.
    menu_actions_after = window.hamburger_button.menu().actions()
    assert menu_actions_before == menu_actions_after

    # Check that any previous update errors are cleared.
    assert updater.dangerzone.settings.get_updater_settings() == expected_settings


def test_update_detected(
    qtbot: QtBot,
    qt_updater: UpdaterThread,
    monkeypatch: MonkeyPatch,
    mocker: MockerFixture,
) -> None:
    """Test that a newly detected version leads to a notification to the user."""

    qt_updater.dangerzone.settings.set("updater_check", True)
    qt_updater.dangerzone.settings.set("updater_last_check", 0)
    qt_updater.dangerzone.settings.set("updater_errors", 9)

    # Make requests.get().json() return the following dictionary.
    mock_upstream_info = {"tag_name": "99.9.9", "body": "changelog"}
    mocker.patch("dangerzone.gui.updater.requests.get")
    requests_mock = updater_module.requests.get
    requests_mock().status_code = 200  # type: ignore [call-arg]
    requests_mock().json.return_value = mock_upstream_info  # type: ignore [attr-defined, call-arg]

    window = MainWindow(qt_updater.dangerzone)
    window.register_update_handler(qt_updater.finished)
    handle_updates_spy = mocker.spy(window, "handle_updates")
    load_svg_spy = mocker.spy(main_window_module, "load_svg_image")

    menu_actions_before = window.hamburger_button.menu().actions()

    with qtbot.waitSignal(qt_updater.finished):
        qt_updater.start()

    menu_actions_after = window.hamburger_button.menu().actions()

    # Check that the callback function gets an update report.
    handle_updates_spy.assert_called_once()
    assert_report_equal(
        handle_updates_spy.call_args.args[0], UpdateReport("99.9.9", "<p>changelog</p>")
    )

    # Check that the settings have been updated properly.
    expected_settings = default_updater_settings()
    expected_settings["updater_check"] = True
    expected_settings["updater_last_check"] = qt_updater.dangerzone.settings.get(
        "updater_last_check"
    )
    expected_settings["updater_latest_version"] = "99.9.9"
    expected_settings["updater_latest_changelog"] = "<p>changelog</p>"
    expected_settings["updater_errors"] = 0
    assert qt_updater.dangerzone.settings.get_updater_settings() == expected_settings

    # Check that the hamburger icon has changed with the expected SVG image.
    assert load_svg_spy.call_count == 2
    assert load_svg_spy.call_args_list[0].args[0] == "hamburger_menu_update_success.svg"
    assert (
        load_svg_spy.call_args_list[1].args[0]
        == "hamburger_menu_update_dot_available.svg"
    )

    # Check that new menu entries have been added.
    menu_actions_after = window.hamburger_button.menu().actions()
    assert len(menu_actions_after) == 5
    assert menu_actions_after[2:] == menu_actions_before

    success_action = menu_actions_after[0]
    assert success_action.text() == "New version available"

    separator = menu_actions_after[1]
    assert separator.isSeparator()

    # Check that clicking in the new menu entry, opens a dialog.
    update_dialog_spy = mocker.spy(main_window_module, "UpdateDialog")

    def check_dialog() -> None:
        dialog = qt_updater.dangerzone.app.activeWindow()

        update_dialog_spy.assert_called_once()
        kwargs = update_dialog_spy.call_args.kwargs
        assert "99.9.9" in kwargs["title"]
        assert "dangerzone.rocks" in kwargs["intro_msg"]
        assert not kwargs["middle_widget"].toggle_button.isChecked()
        collapsible_box = kwargs["middle_widget"]
        text_browser = (
            collapsible_box.layout().itemAt(1).widget().layout().itemAt(0).widget()
        )
        assert collapsible_box.toggle_button.text() == "What's New?"
        assert text_browser.toPlainText() == "changelog"

        height_initial = dialog.sizeHint().height()
        width_initial = dialog.sizeHint().width()

        # Collapse the "What's New" section and ensure that the dialog's height
        # increases.
        with qtbot.waitSignal(collapsible_box.toggle_animation.finished):
            collapsible_box.toggle_button.click()

        assert dialog.sizeHint().height() > height_initial
        assert dialog.sizeHint().width() == width_initial

        # Uncollapse the "What's New" section, and ensure that the dialog's height gets
        # back to the original value.
        with qtbot.waitSignal(collapsible_box.toggle_animation.finished):
            collapsible_box.toggle_button.click()

        assert dialog.sizeHint().height() == height_initial
        assert dialog.sizeHint().width() == width_initial

        dialog.close()

    QtCore.QTimer.singleShot(500, check_dialog)
    success_action.trigger()

    # FIXME: We should check the content of the dialog here.


def test_update_error(
    qtbot: QtBot,
    qt_updater: UpdaterThread,
    monkeypatch: MonkeyPatch,
    mocker: MockerFixture,
) -> None:
    """Test that an error during an update check leads to a notification to the user."""
    # Test 1 - Check that the first error does not notify the user.
    qt_updater.dangerzone.settings.set("updater_check", True)
    qt_updater.dangerzone.settings.set("updater_last_check", 0)
    qt_updater.dangerzone.settings.set("updater_errors", 0)

    # Make requests.get() return an errorthe following dictionary.
    mocker.patch("dangerzone.gui.updater.requests.get")
    requests_mock = updater_module.requests.get
    requests_mock.side_effect = Exception("failed")  # type: ignore [attr-defined]

    window = MainWindow(qt_updater.dangerzone)
    window.register_update_handler(qt_updater.finished)
    handle_updates_spy = mocker.spy(window, "handle_updates")
    load_svg_spy = mocker.spy(main_window_module, "load_svg_image")

    menu_actions_before = window.hamburger_button.menu().actions()

    with qtbot.waitSignal(qt_updater.finished):
        qt_updater.start()

    menu_actions_after = window.hamburger_button.menu().actions()

    # Check that the callback function gets an update report.
    handle_updates_spy.assert_called_once()
    assert "failed" in handle_updates_spy.call_args.args[0].error

    # Check that the settings have been updated properly.
    expected_settings = default_updater_settings()
    expected_settings["updater_check"] = True
    expected_settings["updater_last_check"] = qt_updater.dangerzone.settings.get(
        "updater_last_check"
    )
    expected_settings["updater_errors"] += 1
    assert qt_updater.dangerzone.settings.get_updater_settings() == expected_settings

    # Check that the hamburger icon has not changed.
    assert load_svg_spy.call_count == 0

    # Check that no menu entries have been added.
    assert menu_actions_before == menu_actions_after

    # Test 2 - Check that the second error does not notify the user either.
    qt_updater.dangerzone.settings.set("updater_last_check", 0)
    with qtbot.waitSignal(qt_updater.finished):
        qt_updater.start()

    assert load_svg_spy.call_count == 0

    # Check that the settings have been updated properly.
    expected_settings["updater_errors"] += 1
    expected_settings["updater_last_check"] = qt_updater.dangerzone.settings.get(
        "updater_last_check"
    )
    assert qt_updater.dangerzone.settings.get_updater_settings() == expected_settings

    # Check that no menu entries have been added.
    assert menu_actions_before == menu_actions_after

    # Test 3 - Check that a third error shows a new menu entry.
    qt_updater.dangerzone.settings.set("updater_last_check", 0)
    with qtbot.waitSignal(qt_updater.finished):
        qt_updater.start()

    menu_actions_after = window.hamburger_button.menu().actions()
    assert len(menu_actions_after) == 5
    assert menu_actions_after[2:] == menu_actions_before

    # Check that the hamburger icon has changed with the expected SVG image.
    assert load_svg_spy.call_count == 2
    assert load_svg_spy.call_args_list[0].args[0] == "hamburger_menu_update_error.svg"
    assert (
        load_svg_spy.call_args_list[1].args[0] == "hamburger_menu_update_dot_error.svg"
    )

    error_action = menu_actions_after[0]
    assert error_action.text() == "Update error"

    separator = menu_actions_after[1]
    assert separator.isSeparator()

    # Check that clicking in the new menu entry, opens a dialog.
    update_dialog_spy = mocker.spy(main_window_module, "UpdateDialog")

    def check_dialog() -> None:
        dialog = qt_updater.dangerzone.app.activeWindow()

        update_dialog_spy.assert_called_once()
        kwargs = update_dialog_spy.call_args.kwargs
        assert kwargs["title"] == "Update check error"
        assert "Something went wrong" in kwargs["intro_msg"]
        assert "dangerzone.rocks" in kwargs["intro_msg"]
        assert not kwargs["middle_widget"].toggle_button.isChecked()
        collapsible_box = kwargs["middle_widget"]
        text_browser = (
            collapsible_box.layout().itemAt(1).widget().layout().itemAt(0).widget()
        )
        assert collapsible_box.toggle_button.text() == "Error Details"
        assert "Encountered an exception" in text_browser.toPlainText()
        assert "failed" in text_browser.toPlainText()

        dialog.close()

    QtCore.QTimer.singleShot(500, check_dialog)
    error_action.trigger()


##
# Document Selection tests
##


def test_change_document_button(
    content_widget: ContentWidget,
    qtbot: QtBot,
    mocker: MockerFixture,
    sample_pdf: str,
    sample_doc: str,
    tmp_path: pathlib.Path,
) -> None:
    # Setup first doc selection
    file_dialog_mock = mocker.MagicMock()
    file_dialog_mock.selectedFiles.return_value = (sample_pdf,)
    content_widget.doc_selection_widget.file_dialog = file_dialog_mock

    # Select first file
    with qtbot.waitSignal(content_widget.documents_added):
        qtbot.mouseClick(
            content_widget.doc_selection_widget.dangerous_doc_button,
            QtCore.Qt.MouseButton.LeftButton,
        )
        file_dialog_mock.accept()

    # Setup doc change
    shutil.copy(sample_doc, tmp_path)
    tmp_sample_doc = tmp_path / os.path.basename(sample_doc)
    file_dialog_mock.selectedFiles.return_value = (tmp_sample_doc,)

    # When clicking on "select docs" button
    with qtbot.waitSignal(content_widget.documents_added):
        qtbot.mouseClick(
            content_widget.settings_widget.change_selection_button,
            QtCore.Qt.MouseButton.LeftButton,
        )
        file_dialog_mock.accept()

    # Then two dialogs should have been open
    assert file_dialog_mock.exec.call_count == 2
    assert file_dialog_mock.selectedFiles.call_count == 2

    # Then the final document should be only the second one
    docs = [
        doc.input_filename
        for doc in content_widget.dangerzone.get_unconverted_documents()
    ]
    assert len(docs) == 1
    assert docs[0] == str(tmp_sample_doc)


def test_drop_valid_documents(
    content_widget: ContentWidget,
    drag_valid_files_event: QtGui.QDropEvent,
    qtbot: QtBot,
) -> None:
    with qtbot.waitSignal(
        content_widget.doc_selection_wrapper.documents_selected,
        check_params_cb=lambda x: len(x) == 2 and isinstance(x[0], Document),
    ):
        content_widget.doc_selection_wrapper.dropEvent(drag_valid_files_event)


def test_drop_text(
    content_widget: ContentWidget,
    drag_text_event: QtGui.QDropEvent,
    qtbot: QtBot,
) -> None:
    with qtbot.assertNotEmitted(
        content_widget.doc_selection_wrapper.documents_selected
    ):
        content_widget.doc_selection_wrapper.dropEvent(drag_text_event)


def test_drop_1_invalid_doc(
    content_widget: ContentWidget,
    drag_1_invalid_file_event: QtGui.QDropEvent,
    qtbot: QtBot,
) -> None:
    with qtbot.assertNotEmitted(
        content_widget.doc_selection_wrapper.documents_selected
    ):
        content_widget.doc_selection_wrapper.dropEvent(drag_1_invalid_file_event)


def test_drop_1_invalid_2_valid_documents(
    content_widget: ContentWidget,
    drag_1_invalid_and_2_valid_files_event: QtGui.QDropEvent,
    qtbot: QtBot,
    monkeypatch: MonkeyPatch,
) -> None:
    # If we accept to continue
    monkeypatch.setattr(
        content_widget.doc_selection_wrapper, "prompt_continue_without", lambda x: True
    )

    # Then the 2 valid docs will be selected
    with qtbot.waitSignal(
        content_widget.doc_selection_wrapper.documents_selected,
        check_params_cb=lambda x: len(x) == 2 and isinstance(x[0], Document),
    ):
        content_widget.doc_selection_wrapper.dropEvent(
            drag_1_invalid_and_2_valid_files_event
        )

    # If we refuse to continue
    monkeypatch.setattr(
        content_widget.doc_selection_wrapper, "prompt_continue_without", lambda x: False
    )

    # Then no docs will be selected
    with qtbot.assertNotEmitted(
        content_widget.doc_selection_wrapper.documents_selected,
    ):
        content_widget.doc_selection_wrapper.dropEvent(
            drag_1_invalid_and_2_valid_files_event
        )


def test_not_available_container_tech_exception(
    qtbot: QtBot, mocker: MockerFixture
) -> None:
    # Setup
    mock_app = mocker.MagicMock()
    dummy = Dummy()
    fn = mocker.patch.object(dummy, "is_available")
    fn.side_effect = errors.NotAvailableContainerTechException(
        "podman", "podman image ls logs"
    )

    dz = DangerzoneGui(mock_app, dummy)
    widget = WaitingWidgetContainer(dz)
    qtbot.addWidget(widget)

    # Assert that the error is displayed in the GUI
    if platform.system() in ["Darwin", "Windows"]:
        assert "Dangerzone requires Docker Desktop" in widget.label.text()
    else:
        assert "Podman is installed but cannot run properly" in widget.label.text()

    assert "podman image ls logs" in widget.traceback.toPlainText()


def test_no_container_tech_exception(qtbot: QtBot, mocker: MockerFixture) -> None:
    # Setup
    mock_app = mocker.MagicMock()
    dummy = mocker.MagicMock()

    # Raise
    dummy.is_available.side_effect = errors.NoContainerTechException("podman")

    dz = DangerzoneGui(mock_app, dummy)
    widget = WaitingWidgetContainer(dz)
    qtbot.addWidget(widget)

    # Assert that the error is displayed in the GUI
    if platform.system() in ["Darwin", "Windows"]:
        assert "Dangerzone requires Docker Desktop" in widget.label.text()
    else:
        assert "Dangerzone requires Podman" in widget.label.text()


def test_installation_failure_exception(qtbot: QtBot, mocker: MockerFixture) -> None:
    """Ensures that if an exception is raised during image installation,
    it is shown in the GUI.
    """
    # Setup install to raise an exception
    mock_app = mocker.MagicMock()
    dummy = mocker.MagicMock(spec=Container)
    dummy.install.side_effect = RuntimeError("Error during install")

    dz = DangerzoneGui(mock_app, dummy)

    # Mock the InstallContainerThread to call the original run method instead of
    # starting a new thread
    mocker.patch.object(InstallContainerThread, "start", InstallContainerThread.run)
    widget = WaitingWidgetContainer(dz)
    qtbot.addWidget(widget)

    assert dummy.install.call_count == 1

    assert "Error during install" in widget.traceback.toPlainText()
    assert "RuntimeError" in widget.traceback.toPlainText()


def test_installation_failure_return_false(qtbot: QtBot, mocker: MockerFixture) -> None:
    """Ensures that if the installation returns False, the error is shown in the GUI."""
    # Setup install to return False
    mock_app = mocker.MagicMock()
    dummy = mocker.MagicMock(spec=Container)
    dummy.install.return_value = False

    dz = DangerzoneGui(mock_app, dummy)

    # Mock the InstallContainerThread to call the original run method instead of
    # starting a new thread
    mocker.patch.object(InstallContainerThread, "start", InstallContainerThread.run)
    widget = WaitingWidgetContainer(dz)
    qtbot.addWidget(widget)

    assert dummy.install.call_count == 1

    assert "the following error occured" in widget.label.text()
    assert "The image cannot be found" in widget.traceback.toPlainText()


def test_up_to_date_docker_desktop_does_nothing(
    qtbot: QtBot, mocker: MockerFixture
) -> None:
    # Setup install to return False
    mock_app = mocker.MagicMock()
    dummy = mocker.MagicMock(spec=Container)
    dummy.check_docker_desktop_version.return_value = (True, "1.0.0")
    dz = DangerzoneGui(mock_app, dummy)

    window = MainWindow(dz)
    qtbot.addWidget(window)

    menu_actions = window.hamburger_button.menu().actions()
    assert "Docker Desktop should be upgraded" not in [
        a.toolTip() for a in menu_actions
    ]


def test_outdated_docker_desktop_displays_warning(
    qtbot: QtBot, mocker: MockerFixture
) -> None:
    # Setup install to return False
    mock_app = mocker.MagicMock()
    dummy = mocker.MagicMock(spec=Container)
    dummy.check_docker_desktop_version.return_value = (False, "1.0.0")

    dz = DangerzoneGui(mock_app, dummy)

    load_svg_spy = mocker.spy(main_window_module, "load_svg_image")

    window = MainWindow(dz)
    qtbot.addWidget(window)

    menu_actions = window.hamburger_button.menu().actions()
    assert menu_actions[0].toolTip() == "Docker Desktop should be upgraded"

    # Check that the hamburger icon has changed with the expected SVG image.
    assert load_svg_spy.call_count == 4
    assert (
        load_svg_spy.call_args_list[2].args[0] == "hamburger_menu_update_dot_error.svg"
    )

    alert_spy = mocker.spy(window.alert, "launch")

    # Clicking the menu item should open a warning message
    def _check_alert_displayed() -> None:
        alert_spy.assert_any_call()
        if window.alert:
            window.alert.close()

    QtCore.QTimer.singleShot(0, _check_alert_displayed)
    menu_actions[0].trigger()
