# GPLv3 license
# Copyright Lutra Consulting Limited

import os
from urllib.parse import urlparse
from pathlib import Path
import posixpath
import json

from qgis.core import QgsProject, Qgis, QgsApplication
from qgis.utils import iface, OverrideCursor
from qgis.PyQt.QtWidgets import (
    QMessageBox,
    QDialog,
    QApplication,
    QPushButton,
    QFileDialog,
)
from qgis.PyQt.QtCore import QSettings, Qt, QTimer
from urllib.error import URLError

from .sync_dialog import SyncDialog
from .utils import (
    ClientError,
    ErrorCode,
    InvalidProject,
    get_local_mergin_projects_info,
    LoginError,
    find_qgis_files,
    login_error_message,
    same_dir,
    send_logs,
    storage_limit_fail,
    unhandled_exception_message,
    unsaved_project_check,
    UnsavedChangesStrategy,
    write_project_variables,
    bytes_to_human_size,
    is_file_changed,
    get_push_changes_batch,
    SYNC_ATTEMPTS,
    SYNC_ATTEMPT_WAIT,
    push_error_message,
)
from .utils_auth import AuthSync, AUTH_CONFIG_FILENAME

from .mergin.merginproject import MerginProject
from .project_status_dialog import ProjectStatusDialog
from .mergin.client import AuthTokenExpiredError, MerginClient


class MerginProjectsManager(object):
    """Class for managing Mergin Maps projects in QGIS."""

    def __init__(self, plugin: "MerginPlugin"):  # noqa F821
        self.mc: MerginClient = plugin.mc
        self.iface = iface
        self.plugin = plugin

    @staticmethod
    def unsaved_changes_check(project_dir):
        """
        Check if current project is the same as actually operated Mergin project and has some unsaved changes.
        """
        qgis_proj_filename = os.path.normpath(QgsProject.instance().fileName())
        if qgis_proj_filename in find_qgis_files(project_dir):
            check_result = unsaved_project_check()
            return False if check_result == UnsavedChangesStrategy.HasUnsavedChanges else True
        return True  # not a Mergin project

    def open_project(self, project_dir):
        if not project_dir:
            return

        qgis_files = find_qgis_files(project_dir)
        if len(qgis_files) == 1:
            # singleton project object is not in the interface yet, we need to pass the qgis file to retrieve the project id
            AuthSync(qgis_files[0]).import_auth()

            iface.addProject(qgis_files[0])
            if self.mc.has_unfinished_pull(project_dir):
                widget = iface.messageBar().createMessage(
                    "Mergin Maps",
                    "The previous pull has not finished completely, status of some files may be reported incorrectly.",
                )
                button = QPushButton(widget)
                button.setText("Finish pull")

                def fix_pull():
                    self.close_project_and_fix_pull(project_dir)
                    iface.messageBar().clearWidgets()

                button.pressed.connect(fix_pull)
                widget.layout().addWidget(button)
                iface.messageBar().pushWidget(widget, Qgis.Warning)
        else:
            msg = (
                "Selected project does not contain any QGIS project file"
                if len(qgis_files) == 0
                else "Plugin can only load project with single QGIS project file but {} found.".format(len(qgis_files))
            )
            QMessageBox.warning(None, "Load QGIS project", msg, QMessageBox.StandardButton.Close)

    def create_project(self, project_name, project_dir, is_public, namespace):
        """
        Create new Mergin Maps project.
        If project_dir is None, we are creating empty project without upload.
        """

        full_project_name = "{}/{}".format(namespace, project_name)
        with OverrideCursor(Qt.CursorShape.WaitCursor):
            try:
                self.mc.create_project(full_project_name, is_public)
            except ClientError as e:
                msg = str(e)
                # User friendly error messages
                if e.http_error == 409:
                    msg = (
                        f'Naming conflict with the name "{project_name}" in the workspace "{namespace}".\n\n'
                        f"Details: {e.detail}\n"
                        "Please try renaming the project."
                    )
                elif e.server_code == ErrorCode.ProjectsLimitHit.value:
                    data = e.server_response
                    if isinstance(data, str):
                        try:
                            data = json.loads(data)  # convert string to json
                        except json.JSONDecodeError:
                            data = {}

                    quota = data.get("projects_quota", "unknown")

                    msg = (
                        "Maximum number of projects reached. Please upgrade your subscription to create new projects.\n"
                        f"Projects quota: {quota}"
                    )
                elif e.server_code == ErrorCode.StorageLimitHit.value:
                    data = e.server_response
                    if isinstance(data, str):
                        try:
                            data = json.loads(data)
                        except json.JSONDecodeError:
                            data = {}

                    storage_limit = data.get("storage_limit")
                    human_limit = bytes_to_human_size(storage_limit) if storage_limit is not None else "unknown"

                    msg = f"{e.detail}\nCurrent limit: {human_limit}"

                QMessageBox.critical(
                    None,
                    "Create Project",
                    "Failed to create Mergin Maps project.\n" + msg,
                )
                return False
            except AuthTokenExpiredError:
                self.plugin.auth_token_expired()
            except Exception as e:
                QMessageBox.critical(
                    None,
                    "Create Project",
                    "Failed to create Mergin Maps project.\n" + str(e),
                )
                return False

            if not project_dir:
                # not going to upload anything so just pop a "success" message and exit
                QMessageBox.information(
                    None,
                    "Create Project",
                    "An empty project has been created on the server",
                    QMessageBox.StandardButton.Close,
                )
                return True

            # get project's metadata from the server and store it locally
            try:
                project_info = self.mc.project_info(full_project_name)
                MerginProject.write_metadata(project_dir, project_info)
            except Exception as e:
                QMessageBox.critical(
                    None,
                    "Create Project",
                    "Failed to initialize Mergin Maps project.\n" + str(e),
                )
                return False

        # let's do initial upload of the project data
        mp = MerginProject(project_dir)
        if not mp.inspect_files():
            QMessageBox.warning(
                None,
                "Create Project",
                "The project directory is empty - nothing to upload.",
            )
            return True

        has_changes = True
        while has_changes:
            dlg = SyncDialog()
            dlg.labelStatus.setText("Starting project upload...")
            dlg.push_start(self.mc, project_dir, full_project_name)

            dlg.exec()  # blocks until success, failure or cancellation

            if dlg.exception:
                push_error_message(dlg, project_name, self.plugin, self.mc)
                return True

            if not dlg.is_complete:
                # we were cancelled - but no need to show a message box about that...?
                return True
            _, has_changes = get_push_changes_batch(self.mc, project_dir)

        settings = QSettings()
        server_url = self.mc.url.rstrip("/")
        settings.setValue(f"Mergin/localProjects/{full_project_name}/path", project_dir)
        settings.setValue(f"Mergin/localProjects/{full_project_name}/server", server_url)
        if (
            project_dir == QgsProject.instance().absolutePath()
            or project_dir + "/" in QgsProject.instance().absolutePath()
        ):
            write_project_variables(project_name, full_project_name, "v1")

        QMessageBox.information(
            None,
            "Create Project",
            "Mergin Maps project created and uploaded successfully",
            QMessageBox.StandardButton.Close,
        )

        return True

    def project_status(self, project_dir):
        if project_dir is None:
            return
        check_result = unsaved_project_check()
        if check_result == UnsavedChangesStrategy.HasUnsavedChanges:
            return

        try:
            mp = MerginProject(project_dir)
            project_name = mp.project_full_name()
        except InvalidProject as e:
            msg = f"Failed to get project status:\n\n{str(e)}"
            QMessageBox.critical(None, "Project status", msg, QMessageBox.StandardButton.Close)
            return

        if not self.check_project_server(project_dir):
            return
        try:
            # Skip when the synced project isn't open in QGIS: AuthSync init would fail.
            qgis_proj_filename = os.path.normpath(QgsProject.instance().fileName())
            if qgis_proj_filename in find_qgis_files(project_dir):
                AuthSync().export_auth(self.mc)
            pull_changes, push_changes, push_changes_summary = self.mc.project_status(project_dir)
            dlg = ProjectStatusDialog(
                pull_changes,
                push_changes,
                push_changes_summary,
                self.mc.has_writing_permissions(project_name),
                mp,
                self.mc.project_info(project_name)["role"],
            )
            # Sync button in the status dialog returns QDialog.Accepted
            # and Close button returns QDialog::Rejected, so if dialog was
            # accepted we start sync
            return_value = dlg.exec()

            if return_value == QDialog.DialogCode.Accepted:
                self.sync_project(project_dir)
            elif return_value == QDialog.DialogCode.Rejected:
                for change in push_changes["updated"]:
                    diff = change.get("diff")
                    if diff:
                        diff_path = mp.fpath_meta(diff["path"])
                        if os.path.exists(diff_path):
                            os.remove(diff_path)
            elif return_value == ProjectStatusDialog.RESET_CHANGES:
                self.reset_local_changes(project_dir, dlg.file_to_reset)

        except (URLError, ClientError, InvalidProject) as e:
            msg = f"Failed to get status for project {project_name}:\n\n{str(e)}"
            QMessageBox.critical(None, "Project status", msg, QMessageBox.StandardButton.Close)
        except AuthTokenExpiredError:
            self.plugin.auth_token_expired()
        except LoginError as e:
            login_error_message(e)

    def check_project_server(self, project_dir, inform_user=True):
        """Check if the project was created for current plugin Mergin Maps server."""
        proj_server = None
        for path, owner, name, server in get_local_mergin_projects_info():
            if not same_dir(path, project_dir):
                continue
            proj_server = server
            break
        if proj_server is not None:
            server_urls = [self.mc.url.rstrip("/")]
            url = urlparse(self.mc.url)
            if url.netloc.startswith("dev.dev") or url.netloc.startswith("app.dev"):
                server_urls.append("https://app.dev.merginmaps.com")
                server_urls.append("https://dev.dev.cloudmergin.com")
            elif url.netloc.startswith("test.dev"):
                server_urls.append("https://test.dev.merginmaps.com")
                server_urls.append("https://test.dev.cloudmergin.com")
            elif url.netloc.startswith("public.cloudmergin") or url.netloc.startswith("app.merginmaps"):
                server_urls.append("https://app.merginmaps.com")
                server_urls.append("https://public.cloudmergin.com")
            if proj_server.rstrip("/") in server_urls:
                return True

        if inform_user:
            info = f"Current project was created for another Mergin Maps server:\n{proj_server}\n\n"
            info += "You need to reconfigure Mergin Maps plugin to synchronise the project."
            QMessageBox.critical(None, "Mergin Maps", info)
        return False

    def reset_local_changes(self, project_dir: str, files_to_reset=None):
        if not project_dir:
            return
        if not self.check_project_server(project_dir):
            return

        current_project_filename = os.path.normpath(QgsProject.instance().fileName())
        current_project_path = os.path.normpath(QgsProject.instance().absolutePath())

        # Windows-specific behavior:
        # When a project is opened from this directory, QGIS may keep GPKG file handles
        # for a short time after closing the project. The same workaround is used in
        # `close_project_and_fix_pull()` (unfinished pull handling).
        delay = 0
        if current_project_path == os.path.normpath(project_dir):
            QgsProject.instance().clear()
            QApplication.processEvents()
            delay = 2500  # allow OS to release locked GPKG handles

        def do_reset():
            try:
                self.mc.reset_local_changes(project_dir, files_to_reset)

                if files_to_reset:
                    msg = f"File {files_to_reset} was successfully reset"
                else:
                    msg = "Project local changes were successfully reset"

                QMessageBox.information(
                    None,
                    "Project reset local changes",
                    msg,
                    QMessageBox.StandardButton.Close,
                )

            except Exception as e:
                msg = f"Failed to reset local changes:\n\n{str(e)}"
                QMessageBox.critical(
                    None,
                    "Project reset local changes",
                    msg,
                    QMessageBox.StandardButton.Close,
                )

            # Reopen the project after successful or failed reset
            self.open_project(os.path.dirname(current_project_filename))

        # Run the reset after delay (0 ms on Linux/macOS, 2500 ms on Windows)
        # This mirrors the pattern from unfinished pull resolution.
        QTimer.singleShot(delay, do_reset)

    def sync_project(self, project_dir, project_name=None):
        if not project_dir:
            return
        if not self.unsaved_changes_check(project_dir):
            return
        if project_name is None:
            mp = MerginProject(project_dir)
            try:
                project_name = mp.project_full_name()
            except InvalidProject as e:
                msg = f"Failed to sync project:\n\n{str(e)}"
                QMessageBox.critical(None, "Project syncing", msg, QMessageBox.StandardButton.Close)
                return
        if not self.check_project_server(project_dir):
            return

        # check whether project is in the unfinished pull state and if
        # this is the case, try to resolve it before running sync.
        # Sync will be stopped anyway, as in the process of fixing unfinished
        # pull we create conflicted copies which should be examined by the user
        # to avoid data loss.
        if self.mc.has_unfinished_pull(project_dir):
            self.close_project_and_fix_pull(project_dir)
            return

        try:
            pull_changes, push_changes, push_changes_summary = self.mc.project_status(project_dir)
        except InvalidProject as e:
            msg = f"Project is invalid:\n\n{str(e)}"
            QMessageBox.critical(None, "Project syncing", msg, QMessageBox.StandardButton.Close)
            return

        if not sum(len(v) for v in list(pull_changes.values()) + list(push_changes.values())):
            QMessageBox.information(
                None,
                "Project sync",
                "Project is already up-to-date",
                QMessageBox.StandardButton.Close,
            )
            return

        has_push_changes = True
        error_retries_attempts = 0
        while has_push_changes:
            dlg = SyncDialog()
            pull_timeout = 250
            if error_retries_attempts > 0:
                pull_timeout = SYNC_ATTEMPT_WAIT * 1000
            dlg.labelStatus.setText("Starting project synchronisation...")
            dlg.pull_start(self.mc, project_dir, project_name, pull_timeout)
            dlg.exec()  # blocks until success, failure or cancellation

            if dlg.exception:
                # pull failed for some reason
                if isinstance(dlg.exception, LoginError):
                    login_error_message(dlg.exception)
                elif isinstance(dlg.exception, ClientError):
                    QMessageBox.critical(None, "Project sync", "Client error: " + str(dlg.exception))
                elif isinstance(dlg.exception, AuthTokenExpiredError):
                    self.plugin.auth_token_expired()
                else:
                    unhandled_exception_message(
                        dlg.exception_details(),
                        "Project sync",
                        f"Something went wrong while synchronising your project {project_name}.",
                        self.mc,
                    )
                return

            # after pull project might be in the unfinished pull state. So we
            # have to check and if this is the case, try to close project and
            # finish pull. As in the result we will have conflicted copies created
            # we stop and ask user to examine them.
            if self.mc.has_unfinished_pull(project_dir):
                self.close_project_and_fix_pull(project_dir)
                return

            if dlg.pull_conflicts:
                self.report_conflicts(dlg.pull_conflicts)
                if is_file_changed(pull_changes, AUTH_CONFIG_FILENAME):
                    AuthSync().import_auth()
                return

            if not dlg.is_complete:
                # we were cancelled
                return

            if is_file_changed(pull_changes, AUTH_CONFIG_FILENAME):
                AuthSync().import_auth()

            dlg = SyncDialog()
            dlg.labelStatus.setText("Preparing project upload...")
            dlg.push_start(self.mc, project_dir, project_name)
            dlg.exec()  # blocks until success, failure or cancellation

            qgis_proj_filename = os.path.normpath(QgsProject.instance().fileName())
            qgis_proj_basename = os.path.basename(qgis_proj_filename)
            qgis_proj_changed = False
            for updated in pull_changes["updated"]:
                if updated["path"] == qgis_proj_basename:
                    qgis_proj_changed = True
                    break
            if qgis_proj_filename in find_qgis_files(project_dir) and qgis_proj_changed:
                self.open_project(project_dir)

            if dlg.exception:
                # push failed for some reason
                if isinstance(dlg.exception, LoginError):
                    login_error_message(dlg.exception)
                elif isinstance(dlg.exception, ClientError):
                    if error_retries_attempts < SYNC_ATTEMPTS - 1 and dlg.exception.is_retryable_sync():
                        error_retries_attempts += 1
                        continue  # try again
                    if (
                        dlg.exception.http_error == 400
                        and "Another process" in dlg.exception.detail
                        or dlg.exception.server_code == ErrorCode.AnotherUploadRunning.value
                    ):
                        # To note we check for a string since error in flask doesn't return server error code
                        msg = "Somebody else is syncing, please try again later"
                    elif dlg.exception.server_code == ErrorCode.StorageLimitHit.value:
                        msg = storage_limit_fail(dlg.exception)
                    else:
                        msg = str(dlg.exception)
                    QMessageBox.critical(None, "Project sync", "Client error: \n" + msg)
                elif isinstance(dlg.exception, AuthTokenExpiredError):
                    self.plugin.auth_token_expired()
                else:
                    unhandled_exception_message(
                        dlg.exception_details(),
                        "Project sync",
                        f"Something went wrong while synchronising your project {project_name}.",
                        self.mc,
                    )
                return

            if not dlg.is_complete:
                # we were cancelled
                return

            _, has_push_changes = get_push_changes_batch(self.mc, project_dir)
            error_retries_attempts = 0
            if not has_push_changes:
                # TODO: report success only when we have actually done anything
                msg = "Mergin Maps project {} synchronised successfully".format(project_name)
                QMessageBox.information(None, "Project sync", msg, QMessageBox.StandardButton.Close)
                # clear canvas cache so any changes become immediately visible to users
                self.iface.mapCanvas().clearCache()
                self.iface.mapCanvas().refresh()
            else:
                # we were cancelled - but no need to show a message box about that...?
                pass

    def submit_logs(self, project_dir):
        logs_path = os.path.join(project_dir, ".mergin", "client-log.txt")
        msg = (
            "This action will send a diagnostic log to the developers. "
            "Use this option when you encounter synchronization issues, as the log is "
            "very useful to determine the exact cause of the problem.\n\n"
            "The log does not contain any of your data, only file names. It can be found here:\n"
            "{}\n\nIt would be useful if you also send a mail to support@merginmaps.com "
            "and briefly describe the problem to add more context to the diagnostic log.\n\n"
            "Please click OK if you want to proceed.".format(logs_path)
        )

        btn_reply = QMessageBox.question(
            None,
            "Submit diagnostic logs",
            msg,
            QMessageBox.StandardButton.Ok | QMessageBox.StandardButton.Cancel,
        )
        if btn_reply != QMessageBox.StandardButton.Ok:
            return

        with OverrideCursor(Qt.CursorShape.WaitCursor):
            log_file_name, error = send_logs(self.mc, logs_path)

        if error:
            QMessageBox.warning(
                None,
                "Submit diagnostic logs",
                "Sending of diagnostic logs failed!\n\n{}".format(error),
            )
            return
        QMessageBox.information(
            None,
            "Submit diagnostic logs",
            "Diagnostic logs successfully submitted - thank you!\n\n{}".format(log_file_name),
            QMessageBox.StandardButton.Close,
        )

    def report_conflicts(self, conflicts):
        """
        Shows a dialog with the list of conflicted copies.
        """
        msg = (
            "We have detected conflicting changes between your local copy and "
            "the server that could not be resolved automatically. It is recommended "
            "to manually reconcile your local changes (which have been moved to "
            "conflicted copies) before running sync again.<br>"
            "Following conflicted copies were created:<br><br>"
        )
        for item in conflicts:
            msg += item + "<br>"
        msg += (
            "<br>To learn what are the conflicts about and how to avoid them please check our "
            "<a href='https://merginmaps.com/docs/manage/missing-data/#there-are-conflict-files-in-the-folder'>documentation</a>."
        )
        msg_box = QMessageBox()
        msg_box.setWindowTitle("Conflicts found")
        msg_box.setIcon(QMessageBox.Icon.Warning)
        msg_box.setTextFormat(Qt.TextFormat.RichText)
        msg_box.setStandardButtons(QMessageBox.StandardButton.Ok)
        msg_box.setText(msg)
        msg_box.exec()

    def resolve_unfinished_pull(self, project_dir, reopen_project=False):
        """
        Try to resolve unfinished pull. Shows a dialog with the list of created
        conflict copies on success and an error message on failure.
        """
        try:
            conflicts = self.mc.resolve_unfinished_pull(project_dir)
            self.report_conflicts(conflicts)
        except ClientError as e:
            QMessageBox.critical(None, "Project sync", "Client error: " + str(e))

        if reopen_project:
            self.open_project(project_dir)

    def close_project_and_fix_pull(self, project_dir):
        """
        Close current Mergin Maps project if it is opened in QGIS and try to fix
        unfinished pull.
        """
        delay = 0
        current_project_path = os.path.normpath(QgsProject.instance().absolutePath())
        if current_project_path == os.path.normpath(project_dir):
            QgsProject.instance().clear()
            delay = 2500
        # we have to wait a bit to let the OS (Windows) release lock on the GPKG files
        # otherwise attempt to resolve unfinished pull will fail
        QTimer.singleShot(delay, lambda: self.resolve_unfinished_pull(project_dir, True))

    def download_project(self, project):
        project_name = posixpath.join(project["namespace"], project["name"])  # we need posix path for server API calls
        settings = QSettings()
        last_parent_dir = settings.value("Mergin/lastUsedDownloadDir", str(Path.home()))
        parent_dir = QFileDialog.getExistingDirectory(
            None, "Open Directory", last_parent_dir, QFileDialog.Option.ShowDirsOnly
        )
        if not parent_dir:
            return
        settings.setValue("Mergin/lastUsedDownloadDir", parent_dir)
        target_dir = os.path.abspath(os.path.join(parent_dir, project["name"]))
        if os.path.exists(target_dir):
            QMessageBox.warning(
                None,
                "Download Project",
                "The target directory already exists:\n" + target_dir + "\n\nPlease select a different directory.",
            )
            return

        dlg = SyncDialog()
        dlg.download_start(self.mc, target_dir, project_name)
        dlg.exec()  # blocks until completion / failure / cancellation
        if dlg.exception:
            if isinstance(dlg.exception, (URLError, ValueError)):
                QgsApplication.messageLog().logMessage("Mergin Maps plugin: " + str(dlg.exception))
                msg = (
                    "Failed to download your project {}.\n"
                    "Please make sure your Mergin Maps settings are correct".format(project_name)
                )
                QMessageBox.critical(None, "Project download", msg, QMessageBox.StandardButton.Close)
            elif isinstance(dlg.exception, LoginError):
                login_error_message(dlg.exception)
            else:
                unhandled_exception_message(
                    dlg.exception_details(),
                    "Project download",
                    f"Something went wrong while downloading your project {project_name}.",
                    self.mc,
                    dlg.log_file,
                )
            return
        if not dlg.is_complete:
            return  # either it has been cancelled or an error has been thrown

        settings.setValue("Mergin/localProjects/{}/path".format(project_name), target_dir)
        msg = "Your project {} has been successfully downloaded. Do you want to open project file?".format(project_name)
        btn_reply = QMessageBox.question(
            None,
            "Project download",
            msg,
            QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
            QMessageBox.StandardButton.Yes,
        )
        if btn_reply == QMessageBox.StandardButton.Yes:
            self.open_project(target_dir)

        # reload the Mergin Maps browser entry (in case server is ee/ce)
        browser_model = self.iface.browserModel()
        root_idx = browser_model.findPath("Mergin Maps")
        item = browser_model.dataItem(root_idx)
        item.reload()
