# -*- coding: utf-8 -*-
"""
/***************************************************************************
 QFieldSync
                             -------------------
        begin                : 2020-08-19
        git sha              : $Format:%H$
        copyright            : (C) 2020 by OPENGIS.ch
        email                : info@opengis.ch
 ***************************************************************************/

/***************************************************************************
 *                                                                         *
 *   This program 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.                                   *
 *                                                                         *
 ***************************************************************************/
"""


import shutil
from enum import Enum
from pathlib import Path
from typing import Any, Dict, List, Optional

from PyQt5.QtCore import QUrl
from qgis.PyQt.QtCore import QAbstractListModel, QModelIndex, QObject, Qt, pyqtSignal
from qgis.PyQt.QtNetwork import QNetworkReply

from qfieldsync.core.cloud_api import CloudNetworkAccessManager
from qfieldsync.core.cloud_project import CloudProject, ProjectFile, ProjectFileCheckout
from qfieldsync.libqfieldsync.utils.file_utils import copy_multifile


class CloudTransferrer(QObject):
    # TODO show progress of individual files
    progress = pyqtSignal(float)
    error = pyqtSignal(str, Exception)
    abort = pyqtSignal()
    finished = pyqtSignal()
    upload_progress = pyqtSignal(float)
    download_progress = pyqtSignal(float)
    upload_finished = pyqtSignal()
    download_finished = pyqtSignal()
    delete_finished = pyqtSignal()

    def __init__(
        self, network_manager: CloudNetworkAccessManager, cloud_project: CloudProject
    ) -> None:
        super(CloudTransferrer, self).__init__(parent=None)
        assert cloud_project.local_dir

        self.network_manager = network_manager
        self.cloud_project = cloud_project
        # NOTE these `_files_to_(upload|download|delete)` uses POSIX path as keys, so beware on M$
        self._files_to_upload = {}
        self._files_to_download: Dict[str, ProjectFile] = {}
        self._files_to_delete = {}
        self.total_upload_bytes = 0
        self.total_download_bytes = 0
        self.delete_files_finished = 0
        self.is_aborted = False
        self.is_started = False
        self.is_finished = False
        self.is_upload_active = False
        self.is_download_active = False
        self.is_delete_active = False
        self.is_project_list_update_active = False
        self.replies = []
        self.temp_dir = Path(cloud_project.local_dir).joinpath(".qfieldsync")
        self.error_message = None
        self.throttled_uploader = None
        self.throttled_downloader = None
        self.throttled_deleter = None
        self.transfers_model = None

        if self.temp_dir.exists():
            shutil.rmtree(self.temp_dir)

        self.temp_dir.mkdir()
        self.temp_dir.joinpath("backup").mkdir()
        self.temp_dir.joinpath(FileTransfer.Type.UPLOAD.value).mkdir()
        self.temp_dir.joinpath(FileTransfer.Type.DOWNLOAD.value).mkdir()

        self.upload_finished.connect(self._on_upload_finished)
        self.delete_finished.connect(self._on_delete_finished)
        self.download_finished.connect(self._on_download_finished)

        self.network_manager.logout_success.connect(self._on_logout_success)

    def sync(
        self,
        files_to_upload: List[ProjectFile],
        files_to_download: List[ProjectFile],
        files_to_delete: List[ProjectFile],
    ) -> None:
        assert not self.is_started

        self.is_started = True

        # .qgs/.qgz files should be uploaded the last, since they trigger a new job
        files_to_upload_sorted = [
            f
            for f in sorted(
                files_to_upload,
                key=lambda f: f.path.suffix in (".qgs", ".qgz"),
            )
        ]
        # prepare the files to be uploaded, copy them in a temporary destination
        for project_file in files_to_upload_sorted:
            assert project_file.local_path

            project_file.flush()

            temp_filename = self.temp_dir.joinpath(
                FileTransfer.Type.UPLOAD.value, project_file.name
            )
            temp_filename.parent.mkdir(parents=True, exist_ok=True)
            copy_multifile(project_file.local_path, temp_filename)

            self.total_upload_bytes += project_file.local_size or 0
            self._files_to_upload[str(project_file.path.as_posix())] = project_file

        # prepare the files to be delete, both locally and remotely
        for project_file in files_to_delete:
            self._files_to_delete[str(project_file.path.as_posix())] = project_file

        # prepare the files to be downloaded, download them in a temporary destination
        for project_file in files_to_download:
            temp_filename = self.temp_dir.joinpath(
                FileTransfer.Type.DOWNLOAD.value, project_file.name
            )
            temp_filename.parent.mkdir(parents=True, exist_ok=True)

            self.total_download_bytes += project_file.size or 0
            self._files_to_download[str(project_file.path.as_posix())] = project_file

        self.throttled_uploader = ThrottledFileTransferrer(
            self.network_manager,
            self.cloud_project,
            # note the .qgs/.qgz files are sorted in the end
            list(self._files_to_upload.values()),
            FileTransfer.Type.UPLOAD,
        )
        self.throttled_deleter = ThrottledFileTransferrer(
            self.network_manager,
            self.cloud_project,
            list(self._files_to_delete.values()),
            FileTransfer.Type.DELETE,
        )
        self.throttled_downloader = ThrottledFileTransferrer(
            self.network_manager,
            self.cloud_project,
            list(self._files_to_download.values()),
            FileTransfer.Type.DOWNLOAD,
        )
        self.transfers_model = TransferFileLogsModel(
            [
                self.throttled_uploader,
                self.throttled_deleter,
                self.throttled_downloader,
            ]
        )

        self._make_backup()
        self._upload()

    def _upload(self) -> None:
        assert not self.is_upload_active, "Upload in progress"
        assert not self.is_delete_active, "Delete in progress"
        assert not self.is_download_active, "Download in progress"
        assert self.cloud_project.local_dir

        self.is_upload_active = True

        # nothing to upload
        if len(self._files_to_upload) == 0:
            self.upload_progress.emit(1)
            self.upload_finished.emit()
            # NOTE check _on_upload_finished
            return

        self.throttled_uploader.error.connect(self._on_throttled_upload_error)
        self.throttled_uploader.progress.connect(self._on_throttled_upload_progress)
        self.throttled_uploader.finished.connect(self._on_throttled_upload_finished)
        self.throttled_uploader.transfer()

    def _on_throttled_upload_progress(
        self, filename: str, bytes_transferred: int, _bytes_total: int
    ) -> None:
        fraction = min(bytes_transferred / max(self.total_upload_bytes, 1), 1)
        self.upload_progress.emit(fraction)

    def _on_throttled_upload_error(self, filename: str, error: str) -> None:
        self.throttled_uploader.abort()

    def _on_throttled_upload_finished(self) -> None:
        self.upload_progress.emit(1)
        self.upload_finished.emit()
        return

    def _delete(self) -> None:
        assert not self.is_upload_active, "Upload in progress"
        assert not self.is_delete_active, "Delete in progress"
        assert not self.is_download_active, "Download in progress"

        self.is_delete_active = True

        # nothing to delete
        if len(self._files_to_delete) == 0:
            self.delete_finished.emit()
            # NOTE check _on_delete_finished
            return

        self.throttled_deleter.error.connect(self._on_throttled_delete_error)
        self.throttled_deleter.finished.connect(self._on_throttled_delete_finished)
        self.throttled_deleter.transfer()

        # in case all the files to delete were local only
        if self.delete_files_finished == len(self._files_to_delete):
            self.delete_finished.emit()

    def _on_throttled_delete_error(self, filename: str, error: str) -> None:
        self.throttled_deleter.abort()

    def _on_throttled_delete_finished(self) -> None:
        if self.delete_files_finished == len(self._files_to_delete):
            self.delete_finished.emit()

    def _download(self) -> None:
        assert not self.is_upload_active, "Upload in progress"
        assert not self.is_delete_active, "Delete in progress"
        assert not self.is_download_active, "Download in progress"

        self.is_download_active = True

        # nothing to download
        if len(self._files_to_download) == 0:
            self.download_progress.emit(1)
            self.download_finished.emit()
            return

        self.throttled_downloader.error.connect(self._on_throttled_download_error)
        self.throttled_downloader.progress.connect(self._on_throttled_download_progress)
        self.throttled_downloader.finished.connect(self._on_throttled_download_finished)
        self.throttled_downloader.transfer()

    def _on_throttled_download_progress(
        self, filename: str, bytes_transferred: int, _bytes_total: int
    ) -> None:
        fraction = min(bytes_transferred / max(self.total_download_bytes, 1), 1)
        self.download_progress.emit(fraction)

    def _on_throttled_download_error(self, filename: str, error: str) -> None:
        self.throttled_downloader.abort()

    def _on_throttled_download_finished(self) -> None:
        self.download_progress.emit(1)
        self.download_finished.emit()
        return

    def _on_upload_finished(self) -> None:
        self.is_upload_active = False
        self._update_project_files_list()
        self._delete()

    def _on_delete_finished(self) -> None:
        self.is_delete_active = False
        self._download()

    def _on_download_finished(self) -> None:
        if not self.import_qfield_project():
            return

        self.is_download_active = False
        self.is_finished = True

        if not self.is_project_list_update_active:
            self.finished.emit()

    def _update_project_files_list(self) -> None:
        self.is_project_list_update_active = True

        reply = self.network_manager.projects_cache.get_project_files(
            self.cloud_project.id
        )
        reply.finished.connect(lambda: self._on_update_project_files_list_finished())

    def _on_update_project_files_list_finished(self) -> None:
        self.is_project_list_update_active = False

        if not self.is_download_active:
            if self.error_message:
                return

            self.finished.emit()

    def abort_requests(self) -> None:
        if self.is_aborted:
            return

        self.is_aborted = True

        for transferrer in [
            self.throttled_uploader,
            self.throttled_downloader,
            self.throttled_deleter,
        ]:
            # it might be deleted
            if transferrer:
                transferrer.abort()

        self.abort.emit()

    def _make_backup(self) -> None:
        for project_file in [
            *list(map(lambda f: f, self._files_to_upload.values())),
            *list(map(lambda f: f, self._files_to_download.values())),
        ]:
            if project_file.local_path and project_file.local_path.exists():
                dest = self.temp_dir.joinpath("backup", project_file.path)
                dest.parent.mkdir(parents=True, exist_ok=True)

                copy_multifile(project_file.local_path, dest)

    def _temp_dir2main_dir(self, subdir: str) -> None:
        subdir_path = self.temp_dir.joinpath(subdir)

        if not subdir_path.exists():
            raise Exception(
                self.tr('Directory "{}" does not exist').format(subdir_path)
            )

        for filename in subdir_path.glob("**/*"):
            if filename.is_dir():
                filename.mkdir(parents=True, exist_ok=True)
                continue

            source_filename = str(filename.relative_to(subdir_path).as_posix())
            dest_filename = str(self._files_to_download[source_filename].local_path)

            dest_path = Path(dest_filename)
            if not dest_path.parent.exists():
                dest_path.parent.mkdir(parents=True)

            if source_filename.endswith((".gpkg-shm", ".gpkg-wal")):
                for suffix in ("-shm", "-wal"):
                    source_path = Path(str(self.local_path) + suffix)
                    dest_path = Path(str(dest_filename) + suffix)

                    if source_path.exists():
                        shutil.copyfile(source_path, dest_path)
                    else:
                        dest_path.unlink()

            shutil.copyfile(filename, dest_filename)

    def import_qfield_project(self) -> bool:
        try:
            self._temp_dir2main_dir(
                str(self.temp_dir.joinpath(FileTransfer.Type.DOWNLOAD.value))
            )
            return True
        except Exception as err:
            self.error_message = self.tr(
                "Failed to copy temporary downloaded files to your project directory, restore the project state before the synchronization: {}. Trying to rollback changes..."
            ).format(str(err))
            self.error.emit(
                self.error_message,
                err,
            )
            try:
                self._temp_dir2main_dir(str(self.temp_dir.joinpath("backup")))
            except Exception as errInner:
                self.error_message = self.tr(
                    'Failed to restore the backup. You project might be corrupted! Please check ".qfieldsync/backup" directory and try to copy the files back manually.'
                )
                self.error.emit(
                    self.error_message,
                    errInner,
                )

        return False

    def _on_logout_success(self) -> None:
        self.abort_requests()


class FileTransfer(QObject):

    progress = pyqtSignal(int, int)
    finished = pyqtSignal()

    class Type(Enum):
        DOWNLOAD = "download"
        UPLOAD = "upload"
        DELETE = "delete"

    def __init__(
        self,
        network_manager: CloudNetworkAccessManager,
        cloud_project: CloudProject,
        type: Type,
        file: ProjectFile,
        destination: Path,
        version: str = None,
    ) -> None:
        super(QObject, self).__init__()

        self.network_manager = network_manager
        self.cloud_project = cloud_project
        self.replies: List[QNetworkReply] = []
        self.redirects: List[QUrl] = []
        self.file = file
        self.filename = file.name
        # filesystem filename
        self.fs_filename = destination
        self.fs_filename.parent.mkdir(parents=True, exist_ok=True)
        self.error: Optional[Exception] = None
        self.bytes_transferred = 0
        self.bytes_total = 0
        self.is_aborted = False
        self.is_local_delete = False
        self.is_local_delete_finished = False
        self.type = type
        self.version = version

        if self.file.checkout == ProjectFileCheckout.Local or (
            self.file.checkout & ProjectFileCheckout.Cloud
            and self.file.checkout & ProjectFileCheckout.Local
        ):
            self.is_local_delete = True

    def abort(self):
        if not self.is_started:
            return

        if self.is_finished:
            return

        self.is_aborted = True
        self.last_reply.abort()

    def transfer(self) -> None:
        if self.type == FileTransfer.Type.DOWNLOAD:
            if self.is_redirect:
                reply = self.network_manager.get(
                    self.last_redirect_url, str(self.fs_filename)
                )
            else:
                params = {"version": self.version} if self.version else {}
                reply = self.network_manager.cloud_get(
                    f"files/{self.cloud_project.id}/{self.filename}/",
                    local_filename=str(self.fs_filename),
                    params=params,
                )
        elif self.type == FileTransfer.Type.UPLOAD:
            reply = self.network_manager.cloud_upload_files(
                "files/" + self.cloud_project.id + "/" + self.filename,
                filenames=[str(self.fs_filename)],
            )
        elif self.type == FileTransfer.Type.DELETE:
            if self.is_local_delete:
                try:
                    assert self.file.local_path
                    Path(self.file.local_path).unlink()
                except Exception as err:
                    self.error = err
                finally:
                    self.is_local_delete_finished = True

                self.finished.emit()
                return

            reply = self.network_manager.delete_file(
                self.cloud_project.id + "/" + self.filename + "/"
            )
        else:
            raise NotImplementedError()

        self.replies.append(reply)

        reply.redirected.connect(lambda *args: self._on_redirected(*args))
        reply.downloadProgress.connect(lambda *args: self._on_progress(*args))
        reply.uploadProgress.connect(lambda *args: self._on_progress(*args))
        reply.finished.connect(lambda *args: self._on_finished(*args))

    def _on_progress(self, bytes_transferred: int, bytes_total: int) -> None:
        # there are always at least a few bytes to send, so ignore this situation
        if bytes_transferred < self.bytes_transferred or bytes_total < self.bytes_total:
            return

        self.bytes_transferred = bytes_transferred
        self.bytes_total = bytes_total

        self.progress.emit(bytes_transferred, bytes_total)

    def _on_redirected(self, url: QUrl) -> None:
        self.redirects.append(url)
        self.last_reply.abort()

    def _on_finished(self) -> None:
        if self.is_redirect:
            if self.type == FileTransfer.Type.DOWNLOAD:
                self.transfer()
                return
            else:
                raise NotImplementedError("Redirects on upload are not supported")

        try:
            self.network_manager.handle_response(self.last_reply, False)

            if (
                self.type == FileTransfer.Type.DOWNLOAD
                and not Path(self.fs_filename).is_file()
            ):
                self.error = Exception(
                    f'Downloaded file "{self.fs_filename}" not found!'
                )
        except Exception as err:
            self.error = err

        self.finished.emit()

    @property
    def last_reply(self) -> QNetworkReply:
        if not self.replies:
            raise ValueError("There are no replies yet!")

        return self.replies[-1]

    @property
    def last_redirect_url(self) -> QUrl:
        if not self.replies:
            raise ValueError("There are no redirects!")

        return self.redirects[-1]

    @property
    def is_started(self) -> bool:
        return self.is_local_delete_finished or len(self.replies) > 0

    @property
    def is_finished(self) -> bool:
        if self.is_aborted:
            return True

        if self.is_local_delete_finished:
            return True

        if not self.replies:
            return False

        if self.is_redirect:
            return False

        return self.replies[-1].isFinished()

    @property
    def is_redirect(self) -> bool:
        if not self.replies:
            return False

        return len(self.replies) == len(self.redirects)

    @property
    def is_failed(self) -> bool:
        if self.is_local_delete and self.error:
            return True

        if not self.replies:
            return False

        return self.last_reply.isFinished() and (
            self.error is not None or self.last_reply.error() != QNetworkReply.NoError
        )


class ThrottledFileTransferrer(QObject):
    error = pyqtSignal(str, str)
    finished = pyqtSignal()
    aborted = pyqtSignal()
    file_finished = pyqtSignal(str)
    progress = pyqtSignal(str, int, int)

    def __init__(
        self,
        network_manager,
        cloud_project,
        files: List[ProjectFile],
        transfer_type: FileTransfer.Type,
        max_parallel_requests: int = 8,
    ) -> None:
        super(QObject, self).__init__()

        self.transfers: Dict[str, FileTransfer] = {}
        self.network_manager = network_manager
        self.cloud_project = cloud_project
        self.files = files
        self.filenames = [f.name for f in files]
        self.max_parallel_requests = max_parallel_requests
        self.finished_count = 0
        self.temp_dir = Path(cloud_project.local_dir).joinpath(".qfieldsync")
        self.transfer_type = transfer_type

        for file in self.files:
            transfer = FileTransfer(
                self.network_manager,
                self.cloud_project,
                self.transfer_type,
                file,
                self.temp_dir.joinpath(str(self.transfer_type.value), file.name),
            )
            transfer.progress.connect(
                lambda *args: self._on_transfer_progress(transfer, *args)
            )
            transfer.finished.connect(
                lambda *args: self._on_transfer_finished(transfer, *args)
            )

            assert file.name not in self.transfers

            self.transfers[file.name] = transfer

    def transfer(self):
        transfers_count = 0

        for transfer in self.transfers.values():
            if transfer.is_finished:
                continue

            transfers_count += 1

            # skip a started request but still waiting for a response
            if not transfer.is_started:
                transfer.transfer()

            if transfers_count == self.max_parallel_requests:
                break

    def abort(self) -> None:
        for transfer in self.transfers.values():
            transfer.abort()

        self.aborted.emit()

    def _on_transfer_progress(self, transfer, bytes_received: int, bytes_total: int):
        bytes_received_sum = sum([t.bytes_transferred for t in self.transfers.values()])
        bytes_total_sum = sum([t.bytes_total for t in self.transfers.values()])
        self.progress.emit(transfer.filename, bytes_received_sum, bytes_total_sum)

    def _on_transfer_finished(self, transfer: FileTransfer) -> None:
        self.transfer()

        if transfer.error:
            if transfer.type == FileTransfer.Type.DOWNLOAD:
                msg = self.tr('Downloading file "{}" failed!').format(
                    transfer.fs_filename
                )
            elif transfer.type == FileTransfer.Type.UPLOAD:
                msg = self.tr('Uploading file "{}" failed!').format(
                    transfer.fs_filename
                )
            elif transfer.type == FileTransfer.Type.DELETE:
                msg = self.tr('Deleting file "{}" failed!').format(transfer.fs_filename)
            else:
                raise NotImplementedError()

            self.error.emit(
                transfer.filename,
                msg,
            )

        self.finished_count += 1
        self.file_finished.emit(transfer.filename)

        if self.finished_count == len(self.transfers):
            self.finished.emit()
            return


class TransferFileLogsModel(QAbstractListModel):
    def __init__(
        self, transferrers: List[ThrottledFileTransferrer], parent: QObject = None
    ):
        super(TransferFileLogsModel, self).__init__()
        self.transfers: List[FileTransfer] = []
        self.filename_to_index: Dict[str, int] = {}

        for transferrer in transferrers:
            for filename, transfer in transferrer.transfers.items():
                self.filename_to_index[filename] = len(self.transfers)
                self.transfers.append(transfer)

            transferrer.file_finished.connect(self._on_updated_transfer)
            transferrer.error.connect(self._on_updated_transfer)
            transferrer.progress.connect(self._on_updated_transfer)

    def rowCount(self, parent: QModelIndex) -> int:
        return len(self.transfers)

    def data(self, index: QModelIndex, role: int) -> Any:
        if index.row() < 0 or index.row() >= self.rowCount(QModelIndex()):
            return None

        if role == Qt.DisplayRole:
            return self._data_string(self.transfers[index.row()])

        return None

    def index(self, row: int, col: int, _index: QModelIndex) -> QModelIndex:
        return self.createIndex(row, col)

    def _data_string(self, transfer: FileTransfer) -> str:
        error_msg = ""
        if transfer.is_failed:
            error_msg = (
                str(transfer.error)
                if transfer.error
                else "[{}] {}".format(
                    transfer.last_reply.error(), transfer.last_reply.errorString()
                )
            )

        if transfer.type == FileTransfer.Type.DOWNLOAD:
            if transfer.is_aborted:
                return self.tr('Aborted "{}" download'.format(transfer.filename))
            elif transfer.is_failed:
                return self.tr(
                    'Failed to download "{}": {}'.format(transfer.filename, error_msg)
                )
            elif transfer.is_finished:
                return self.tr('Downloaded "{}"'.format(transfer.filename))
            elif transfer.is_started:
                percentage = (
                    transfer.bytes_transferred / transfer.bytes_total
                    if transfer.bytes_total > 0
                    else 0
                )
                return self.tr(
                    'Downloading "{}" {}%'.format(
                        transfer.filename, round(percentage * 100)
                    )
                )
            else:
                return self.tr('File to download "{}"'.format(transfer.filename))
        elif transfer.type == FileTransfer.Type.UPLOAD:
            if transfer.is_aborted:
                return self.tr('Aborted "{}" upload'.format(transfer.filename))
            elif transfer.is_failed:
                return self.tr(
                    'Failed to upload "{}": {}'.format(transfer.filename, error_msg)
                )
            elif transfer.is_finished:
                return self.tr('Uploaded "{}"'.format(transfer.filename))
            elif transfer.is_started:
                percentage = (
                    transfer.bytes_transferred / transfer.bytes_total
                    if transfer.bytes_total > 0
                    else 0
                )
                return self.tr(
                    'Uploading "{}" {}%'.format(
                        transfer.filename, round(percentage * 100)
                    )
                )
            else:
                return self.tr('File to upload "{}"'.format(transfer.filename))
        elif transfer.type == FileTransfer.Type.DELETE:
            if transfer.file.checkout & ProjectFileCheckout.Cloud:
                if transfer.is_aborted:
                    return self.tr(
                        'Aborted "{}" deleting one the cloud'.format(transfer.filename)
                    )
                elif transfer.is_failed:
                    return self.tr(
                        'Failed delete "{}" on the cloud'.format(transfer.filename)
                    )
                elif transfer.is_finished:
                    return self.tr(
                        'File "{}" deleted on the cloud'.format(transfer.filename)
                    )
                elif transfer.is_started:
                    return self.tr(
                        'Deleting "{}" on the cloud'.format(transfer.filename)
                    )
                else:
                    return self.tr(
                        'File "{}" will be deleted on the cloud'.format(
                            transfer.filename
                        )
                    )
            else:
                if transfer.is_aborted:
                    return self.tr(
                        'Aborted "{}" deleting locally'.format(transfer.filename)
                    )
                elif transfer.is_failed:
                    return self.tr(
                        'Failed delete "{}" locally'.format(transfer.filename)
                    )
                elif transfer.is_finished:
                    return self.tr(
                        'File "{}" locally deleted'.format(transfer.filename)
                    )
                elif transfer.is_started:
                    return self.tr('Locally deleting "{}"'.format(transfer.filename))
                else:
                    return self.tr(
                        'File "{}" to will be locally deleted'.format(transfer.filename)
                    )
        else:
            raise NotImplementedError("Unknown transfer type")

    def _on_updated_transfer(self, filename, *args) -> None:
        row = self.filename_to_index[filename]
        index = self.createIndex(row, 0)

        self.dataChanged.emit(index, index, [Qt.DisplayRole])
