# Copyright (C) 2026 pytest-qfield Contributors.
#
#
# This file is part of pytest-qfield.
#
# pytest-qfield is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 2 of the License, or
# (at your option) any later version.
#
# pytest-qfield is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with pytest-qfield.  If not, see <https://www.gnu.org/licenses/>.
import shutil
from contextlib import suppress
from pathlib import Path
from typing import TYPE_CHECKING, Any, Protocol, cast

import pytest_qgis.utils
from PyQt6.QtCore import QObject, QPointF, Qt, QtMsgType, QUrl
from PyQt6.QtQml import QQmlComponent
from PyQt6.QtQuick import QQuickItem
from qgis.core import QgsProject

if TYPE_CHECKING:
    from PyQt6.QtQml import QQmlApplicationEngine
    from pytestqt.logging import _QtMessageCapture
    from pytestqt.qtbot import QtBot

    from pytest_qfield.stub_interface.qfield_stubs import QFieldAppInterfaceStub

QML_JS_QOBJECT_TEMPLATE = """
import QtQml
import "{js_file_name}" as Logic

QtObject {{
    function call({params}) {{
        return Logic.{function_name}({params})
    }}
}}

"""


class JsQObject(Protocol):
    """
    Protocol for JavaScript QObject with a call method.
    """

    @staticmethod
    def call(*args: Any) -> Any:  # noqa: ANN401
        """
        Function to call the JavaScript function with the given parameters.
        :param args: Parameters to call the function with.
        :return: A possible return value from the function
        """
        ...


class QFieldBot:
    """
    Helper class for testing QField QML files.
    """

    def __init__(
        self,
        qfield_iface: "QFieldAppInterfaceStub",
        qml_engine: "QQmlApplicationEngine",
        qtbot: "QtBot",
        qtlog: "_QtMessageCapture",
        tmp_path: "Path",
    ) -> None:
        self.iface = qfield_iface
        self._qml_engine = qml_engine
        self.qtbot = qtbot
        self.qtlog = qtlog
        self._tmp_path = tmp_path
        self._plugin_loaded = False
        self._plugin_root_object: QObject | None = None

    def show_window(self) -> None:
        """
        Show the QField application window.
        """
        self.iface.show()
        QgsProject.instance().layersAdded.connect(
            self.sync_canvas_layers_to_project_layer_tree
        )

    def open_project(self, project_file: Path) -> None:
        """
        Open a QField project file.

        If you load the plugin before opening the project, remember to load it with
        emit_load_project_ended=False.

        :param project_file: Path to the QGIS project file to open.
        """
        QgsProject.instance().clear()
        with suppress(TypeError):
            QgsProject.instance().layersAdded.disconnect(
                self.sync_canvas_layers_to_project_layer_tree
            )
        try:
            with self.qtbot.waitSignal(QgsProject.instance().readProject):
                assert QgsProject.instance().read(str(project_file))
            self.sync_canvas_layers_to_project_layer_tree()
            self.emit_load_project_ended()
        finally:
            QgsProject.instance().layersAdded.connect(
                self.sync_canvas_layers_to_project_layer_tree
            )

    def load_plugin(
        self,
        qfield_plugin_qml_file: Path,
        raise_if_warnings: bool = True,
        emit_load_project_ended: bool = True,
    ) -> None:
        """
        Load a QField plugin QML file into the QML engine and wait for it to be loaded.

        :param qfield_plugin_qml_file: Path to the QML file to load.
        :param raise_if_warnings: Whether to raise an exception
                if any warnings or errors are logged during loading.
        :param emit_load_project_ended: Whether to emit the loadProjectEnded
                signal on iface.
        """

        self._plugin_root_object = self.load_qml(
            qfield_plugin_qml_file, raise_if_warnings
        )
        self.iface.set_qml_root(self._plugin_root_object)
        self._plugin_loaded = True
        if emit_load_project_ended:
            self.emit_load_project_ended()

    def emit_load_project_ended(self) -> None:
        """Emit the loadProjectEnded signal on iface after initial setup."""
        self.iface.loadProjectEnded.emit()

    def load_js_function(
        self,
        js_file: Path,
        name: str,
        params: list[str],
        extra_files: list[Path] | None = None,
    ) -> JsQObject:
        """
        Load js function from file as a QObject with function "call".
        Call object.call to execute the function.

        :param js_file: Path to the JavaScript file to load.
        :param name: Name of the function to load.
        :param params: List of parameter names for the function.
        :param extra_files: List of extra files to load.
        :return: JsQObject with the loaded function
        """

        for file in [js_file, *(extra_files if extra_files else [])]:
            shutil.copy(file, self._tmp_path / file.name)
        qml_code = QML_JS_QOBJECT_TEMPLATE.format(
            js_file_name=js_file.name, function_name=name, params=", ".join(params)
        )
        main_qml = self._tmp_path / "main.qml"
        main_qml.touch()
        main_qml.write_text(qml_code)

        component = QQmlComponent(self._qml_engine)
        component.loadUrl(QUrl.fromLocalFile(str(main_qml)))
        js_object = component.create()
        if js_object is None:
            raise ValueError(
                "Could not create QObject with js function: ",
                [error.toString() for error in component.errors()],
            )
        return cast("JsQObject", js_object)

    def get_item(self, object_name: str) -> "QQuickItem":
        self._ensure_plugin_loaded()
        child = self._get_plugin_root_object().findChild(QQuickItem, object_name)
        if not child:
            raise RuntimeError(f"QML object {object_name} not found!")
        return child

    def click_item(
        self,
        item: "QQuickItem",
        mouse_button: Qt.MouseButton = Qt.MouseButton.LeftButton,
    ) -> None:
        center = QPointF(item.width(), item.height()) / 2
        self.qtbot.mouseClick(
            item.window(), mouse_button, pos=item.mapToScene(center).toPoint()
        )

    def load_qml(self, qml_file: Path, raise_if_warnings: bool = True) -> QObject:
        """
        Load a QML file into the QML engine and wait for it to be loaded.

        :param qml_file: Path to the QML file to load.
        :param raise_if_warnings: Whether to raise an exception
                if any warnings or errors are logged during loading.
        """
        initial_log_count = len(self.qtlog.records)
        initial_root_count = len(self._qml_engine.rootObjects())

        if not qml_file.exists():
            raise FileNotFoundError(f"QML file {qml_file} does not exist!")
        with self.qtbot.waitSignal(self._qml_engine.objectCreated):
            self._qml_engine.load(str(qml_file))

        new_logs = [
            (m.type, m.message.strip())
            for m in self.qtlog.records[initial_log_count:]
            if m.type
            in (QtMsgType.QtWarningMsg, QtMsgType.QtCriticalMsg, QtMsgType.QtFatalMsg)
        ]
        if raise_if_warnings and new_logs:
            raise ValueError(f"QML file {qml_file} failed to load:\n{new_logs}")

        if not len(self._qml_engine.rootObjects()) > initial_root_count:
            raise ValueError(f"QML file {qml_file} did not load successfully!")

        return self._qml_engine.rootObjects()[-1]

    def set_map_crs_based_on_layers(self) -> None:
        """Set map crs based on layers of the project."""
        # Copied here for convenience
        pytest_qgis.utils.set_map_crs_based_on_layers()

    def _ensure_plugin_loaded(self) -> None:
        if not self._plugin_loaded:
            raise ValueError("Plugin not loaded yet!")

    def _get_plugin_root_object(self) -> "QObject":
        if self._plugin_root_object is None:
            raise ValueError("Plugin not loaded yet!")
        return self._plugin_root_object

    def sync_canvas_layers_to_project_layer_tree(self, *_: Any) -> None:  # noqa: ANN401
        """
        Set map canvas layers in project layer tree order.

        This is alternative way compared to settings up the QgsLayerTreeMapCanvasBridge,
        which does not seem to cope well with web map services.
        """
        root = QgsProject.instance().layerTreeRoot()
        ordered_layers = []
        for layer in root.layerOrder():
            layer_node = root.findLayer(layer.id())
            if layer_node is None:
                continue
            is_visible = (
                layer_node.itemVisibilityChecked()
                if hasattr(layer_node, "itemVisibilityChecked")
                else layer_node.isVisible()
            )
            if is_visible:
                ordered_layers.append(layer)
        self.iface.qgis_map_canvas.setLayers(ordered_layers)
        self.iface.qgis_map_canvas.refreshAllLayers()
