# worker.py
#
# Copyright 2020 Martin Abente Lahaye
#
# 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 3 of the License, or
# (at your option) any later version.
#
# This program 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 this program.  If not, see <http://www.gnu.org/licenses/>.

import os
import sys
import stat
import time
import shutil
import locale
import datetime
import threading

from pwd import getpwuid
from grp import getgrgid

from gi.repository import Gio, GObject, GLib

from . import utils
from . import logger
from .cache import default_cache
from .translation import gettext as _
from .trash import default_trash


class WorkerStoppedException(Exception):
    pass


class CachedWorker(object):
    def __init__(self):
        default_cache.activate()

    def __del__(self):
        default_cache.deactivate()


class PortfolioWorker(threading.Thread, GObject.GObject):
    __gtype_name__ = "PortfolioWorker"

    def __init__(self):
        threading.Thread.__init__(self)
        GObject.GObject.__init__(self)
        self._cancellable = Gio.Cancellable()

    def _progress(self, current, total):
        logger.debug(current, total)

    def _stop_check(self):
        if self._cancellable.is_cancelled():
            raise WorkerStoppedException()

    def stop(self):
        if not self._cancellable.is_cancelled():
            self._cancellable.cancel()

    def emit(self, *args):
        GLib.idle_add(GObject.GObject.emit, self, *args)


class PortfolioCopyWorker(PortfolioWorker):
    __gtype_name__ = "PortfolioCopyWorker"

    __gsignals__ = {
        "started": (GObject.SignalFlags.RUN_LAST, None, (int,)),
        "updated": (GObject.SignalFlags.RUN_LAST, None, (str, int, int, float, float)),
        "post-update": (GObject.SignalFlags.RUN_LAST, None, (str, str, object, bool)),
        "finished": (GObject.SignalFlags.RUN_LAST, None, (int,)),
        "failed": (GObject.SignalFlags.RUN_LAST, None, (str,)),
        "stopped": (GObject.SignalFlags.RUN_LAST, None, ()),
    }

    def __init__(self, selection, directory=None):
        super().__init__()
        self._selection = selection
        self._directory = directory
        self._copy_was_called = False

    def _report_status(self, path, current_bytes, total_bytes):
        self.emit(
            "updated",
            path,
            self._count,
            self._total,
            current_bytes,
            total_bytes,
        )

    def _do_copy(self, source, destination):
        current_bytes = 0
        total_bytes = os.stat(source.name).st_size
        arch_capped = sys.maxsize < 2**32

        # start with an arbitrary block size
        block_min_bytes = 2**23
        block_bytes = block_min_bytes
        last_sync = time.monotonic()

        outfd = destination.fileno()
        infd = source.fileno()

        while True:
            self._stop_check()

            # assume kernel >= 2.6.33
            sent_bytes = os.sendfile(outfd, infd, current_bytes, block_bytes)
            if sent_bytes == 0:
                break

            # fsync frequency is indirectly controlled by the size of the block
            os.fsync(outfd)

            # now dynamically adjust the block size so that it
            # will keep fsync frequency between 750 and 1250ms
            now = time.monotonic()
            if now - last_sync > 1.25:
                block_bytes //= 2
            elif now - last_sync < 0.75:
                block_bytes *= 2

            # on 32 bit arch block size must be capped
            if arch_capped:
                block_bytes = min(block_bytes, 2**30)

            # some lower and upper bounds
            block_bytes = max(block_bytes, block_min_bytes)
            block_bytes = min(block_bytes, total_bytes)

            last_sync = now
            current_bytes += sent_bytes
            self._report_status(destination.name, current_bytes, total_bytes)

        logger.debug(f"sendfile block size converged to {block_bytes}")

    def _copy(self, source_path, destination_path):
        if os.path.islink(source_path):
            os.symlink(os.readlink(source_path), destination_path)
            return

        with open(source_path, "rb") as source:
            with open(destination_path, "wb") as destination:
                self._do_copy(source, destination)

        shutil.copymode(source_path, destination_path)

        self._count += 1
        self._copy_was_called = True

    def run(self):
        self._count = 0
        self._total = sum([utils.count(path) for path, ref in self._selection])

        self.emit("started", self._total)

        for path, ref in self._selection:
            name = os.path.basename(path)
            destination = os.path.join(self._directory, name)
            overwritten = os.path.lexists(destination)

            if path == destination:
                name = utils.find_new_name(self._directory, name)
                destination = os.path.join(self._directory, name)
                overwritten = False

            try:
                self._stop_check()

                if os.path.isdir(path):
                    if overwritten and os.path.isdir(path):
                        shutil.rmtree(destination)
                    shutil.copytree(path, destination, copy_function=self._copy)
                else:
                    self._copy(path, destination)
            except WorkerStoppedException:
                self.emit("stopped")
                return
            except Exception as e:
                logger.debug(e)
                self.emit("failed", destination)
                return
            else:
                utils.sync_folder(os.path.dirname(destination))
                self.emit(
                    "post-update",
                    os.path.basename(destination),
                    destination,
                    utils.get_file_icon(destination),
                    overwritten,
                )

        self.emit("finished", self._total)


class PortfolioCutWorker(PortfolioCopyWorker):
    __gtype_name__ = "PortfolioCutWorker"

    def run(self):
        self._count = 0
        self._total = sum([utils.count(path) for path, ref in self._selection])

        self.emit("started", self._total)

        for path, ref in self._selection:
            name = os.path.basename(path)
            destination = os.path.join(self._directory, name)
            overwritten = os.path.lexists(destination)

            try:
                self._stop_check()

                if destination == path:
                    continue
                if overwritten and os.path.isdir(path):
                    shutil.rmtree(destination)
                if overwritten and os.path.islink(destination):
                    os.unlink(destination)
                shutil.move(path, destination, copy_function=self._copy)
            except WorkerStoppedException:
                self.emit("stopped")
                return
            except Exception as e:
                logger.debug(e)
                self.emit("failed", path)
                return
            else:
                utils.sync_folder(os.path.dirname(destination))

                # XXX force report even if count is not precise
                if self._copy_was_called is False:
                    self._count += 1
                    total_bytes = os.lstat(destination).st_size
                    self._report_status(destination, total_bytes, total_bytes)

                self.emit(
                    "post-update",
                    os.path.basename(destination),
                    destination,
                    utils.get_file_icon(destination),
                    overwritten,
                )

        self.emit("finished", self._total)


class PortfolioDeleteWorker(GObject.GObject, CachedWorker):
    __gtype_name__ = "PortfolioDeleteWorker"

    __gsignals__ = {
        "started": (GObject.SignalFlags.RUN_LAST, None, ()),
        "pre-update": (GObject.SignalFlags.RUN_LAST, None, (str,)),
        "updated": (GObject.SignalFlags.RUN_LAST, None, (str, object, int, int)),
        "finished": (GObject.SignalFlags.RUN_LAST, None, (int,)),
        "failed": (GObject.SignalFlags.RUN_LAST, None, (str,)),
        "stopped": (GObject.SignalFlags.RUN_LAST, None, ()),
    }

    def __init__(self, selection):
        super().__init__()
        CachedWorker.__init__(self)
        self._selection = selection
        self._timeout_handler_id = None

    def start(self):
        self.emit("started")

        try:
            paths = []
            for path, ref in self._selection:
                paths += utils.flatten_walk(path)
        except Exception as e:
            logger.debug(e)
            self.emit("failed", path)
            return

        self._paths = paths
        self._refs = dict(self._selection)

        self._total = len(paths)
        self._index = 0
        self._timeout_handler_id = GLib.idle_add(
            self.step, priority=GLib.PRIORITY_HIGH_IDLE + 20
        )

    def step(self):
        if self._index >= self._total:
            self.emit("finished", self._total)
            return

        path = self._paths[self._index]

        try:
            self.emit("pre-update", path)
            if os.path.isdir(path):
                os.rmdir(path)
            else:
                os.unlink(path)
        except Exception as e:
            logger.debug(e)
            self.emit("failed", path)
            return

        self._index += 1
        self.emit("updated", path, self._refs.get(path), self._index, self._total)
        self._timeout_handler_id = GLib.idle_add(
            self.step, priority=GLib.PRIORITY_HIGH_IDLE + 20
        )

    def stop(self):
        if self._timeout_handler_id is not None:
            GLib.Source.remove(self._timeout_handler_id)
            self._timeout_handler_id = None
        self.emit("stopped")


class PortfolioLoadWorker(GObject.GObject, CachedWorker):
    __gtype_name__ = "PortfolioLoadWorker"

    __gsignals__ = {
        "started": (GObject.SignalFlags.RUN_LAST, None, (str,)),
        "updated": (GObject.SignalFlags.RUN_LAST, None, (str, object, int, int)),
        "finished": (GObject.SignalFlags.RUN_LAST, None, (str,)),
        "failed": (GObject.SignalFlags.RUN_LAST, None, (str,)),
    }

    BUFFER = 75

    def __init__(self, directory, hidden=False):
        super().__init__()
        CachedWorker.__init__(self)
        self._directory = directory
        self._hidden = hidden
        self._timeout_handler_id = None

    def start(self):
        self.emit("started", self._directory)

        try:
            self._paths = os.listdir(self._directory)
        except Exception as e:
            logger.debug(e)
            self.emit("failed", self._directory)
            return

        self._total = len(self._paths)
        self._index = 0
        self._timeout_handler_id = GLib.idle_add(
            self.step, priority=GLib.PRIORITY_HIGH_IDLE + 20
        )

    def step(self):
        if self._index >= self._total:
            self.emit("finished", self._directory)
            return

        found = []
        for index in range(0, self.BUFFER):
            if self._index + index < self._total:
                name = self._paths[self._index + index]
                if not self._hidden and name.startswith("."):
                    continue
                path = os.path.join(self._directory, name)
                found.append((name, path, utils.get_file_icon(path)))

        self._index += self.BUFFER
        self.emit("updated", self._directory, found, self._index, self._total)
        self._timeout_handler_id = GLib.idle_add(
            self.step, priority=GLib.PRIORITY_HIGH_IDLE + 20
        )

    def stop(self):
        if self._timeout_handler_id is None:
            return
        GLib.Source.remove(self._timeout_handler_id)
        self._timeout_handler_id = None


class PortfolioOpenWorker(GObject.GObject):
    __gtype_name__ = "PortfolioOpenWorker"

    __gsignals__ = {
        "started": (GObject.SignalFlags.RUN_LAST, None, ()),
        "updated": (GObject.SignalFlags.RUN_LAST, None, ()),
        "finished": (GObject.SignalFlags.RUN_LAST, None, ()),
        "failed": (GObject.SignalFlags.RUN_LAST, None, (str,)),
    }

    def __init__(self, path):
        super().__init__()
        self._path = path
        self._timeout_handler_id = None
        self._is_flatpak = utils.is_flatpak()

    def start(self):
        self.emit("started")

        uri = GLib.Uri.build(
            GLib.UriFlags.NONE, "file", None, None, -1, self._path, None, None
        ).to_string()
        Gio.AppInfo.launch_default_for_uri_async(
            uri, None, None, self._on_launch_finished, None
        )
        self._timeout_handler_id = GLib.timeout_add(100, self._on_step)

    def stop(self):
        pass

    def _on_step(self):
        self.emit("updated")
        return True

    def _on_launch_finished(self, worker, result, data=None):
        if self._timeout_handler_id is not None:
            GLib.Source.remove(self._timeout_handler_id)
            self._timeout_handler_id = None

        if result.had_error():
            self.emit("failed", self._path)
        else:
            self.emit("finished")


class PortfolioPropertiesWorker(GObject.GObject):
    class InnerWorker(threading.Thread):
        def __init__(self, worker):
            super().__init__()
            self._stop = False
            self._worker = worker

        def stop(self):
            self._stop = True

        def run(self):
            size = 0

            for directory, folders, files in os.walk(self._worker._path):
                for filename in files:
                    if self._stop:
                        return
                    try:
                        size += os.lstat(os.path.join(directory, filename)).st_size
                    except:
                        pass

            self._worker._size = utils.get_size_for_humans(size)
            GLib.idle_add(self._worker.notify, "size")

    def __init__(self):
        super().__init__()

        self._path = ""
        self._name = ""
        self._location = ""
        self._type = ""
        self._size = ""
        self._created = ""
        self._modified = ""
        self._accessed = ""
        self._permissions_owner = ""
        self._permissions_group = ""
        self._permissions_others = ""
        self._owner = ""
        self._group = ""

        self._inner_worker = self.InnerWorker(self)

    def _get_file_size(self):
        return os.lstat(self._path).st_size

    def _update_size(self):
        if os.path.isdir(self._path):
            self._inner_worker = self.InnerWorker(self)
            self._inner_worker.start()
        else:
            self._size = utils.get_size_for_humans(self._get_file_size())
            self.notify("size")

    def _get_human_time(self, timestamp):
        fmt = locale.nl_langinfo(locale.D_T_FMT)
        return datetime.datetime.fromtimestamp(timestamp).strftime(fmt)

    def _get_human_permissions(self, path, target="owner"):
        permissions = []
        mode = os.lstat(path).st_mode

        if target == "owner":
            can_read = stat.S_IRUSR
            can_write = stat.S_IWUSR
            can_exec = stat.S_IXUSR
        elif target == "group":
            can_read = stat.S_IRGRP
            can_write = stat.S_IWGRP
            can_exec = stat.S_IXGRP
        else:
            can_read = stat.S_IROTH
            can_write = stat.S_IWOTH
            can_exec = stat.S_IXOTH

        if mode & can_read:
            permissions.append(_("read"))
        if mode & can_write:
            permissions.append(_("write"))
        if mode & can_exec:
            permissions.append(_("execute"))

        if permissions:
            return ", ".join(permissions)

        return _("do nothing")

    def _get_human_owner(self, path):
        owner_id = os.lstat(path).st_uid
        owner = getpwuid(owner_id).pw_name
        return owner

    def _get_human_group(self, path):
        group_id = os.lstat(path).st_gid
        group = getgrgid(group_id).gr_name
        return group

    @GObject.Property(type=str)
    def name(self):
        return self._name

    @GObject.Property(type=str)
    def location(self):
        return self._location

    @GObject.Property(type=str)
    def type(self):
        return self._type

    @GObject.Property(type=str)
    def size(self):
        return self._size

    @GObject.Property(type=str)
    def created(self):
        return self._created

    @GObject.Property(type=str)
    def modified(self):
        return self._modified

    @GObject.Property(type=str)
    def accessed(self):
        return self._accessed

    @GObject.Property(type=str)
    def permissions_owner(self):
        return self._permissions_owner

    @GObject.Property(type=str)
    def permissions_group(self):
        return self._permissions_group

    @GObject.Property(type=str)
    def permissions_others(self):
        return self._permissions_others

    @GObject.Property(type=str)
    def owner(self):
        return self._owner

    @GObject.Property(type=str)
    def group(self):
        return self._group

    @GObject.Property(type=str)
    def path(self):
        return self._path

    @path.setter
    def path(self, path):
        self._path = path

        self._inner_worker.stop()

        file = Gio.File.new_for_path(path)
        info = file.query_info(
            Gio.FILE_ATTRIBUTE_STANDARD_CONTENT_TYPE,
            Gio.FileQueryInfoFlags.NOFOLLOW_SYMLINKS,
            None,
        )

        self._name = os.path.basename(self._path)
        self._location = os.path.dirname(self._path)
        self._type = info.get_content_type()
        self._size = _("Calculating...")
        self._created = self._get_human_time(os.lstat(self._path).st_ctime)
        self._modified = self._get_human_time(os.lstat(self._path).st_mtime)
        self._accessed = self._get_human_time(os.lstat(self._path).st_atime)
        self._permissions_owner = self._get_human_permissions(self._path, "owner")
        self._permissions_group = self._get_human_permissions(self._path, "group")
        self._permissions_others = self._get_human_permissions(self._path, "others")
        self._owner = self._get_human_owner(self._path)
        self._group = self._get_human_group(self._path)

        self.notify("name")
        self.notify("location")
        self.notify("type")
        self.notify("size")
        self.notify("created")
        self.notify("modified")
        self.notify("accessed")
        self.notify("permissions_owner")
        self.notify("permissions_group")
        self.notify("permissions_others")
        self.notify("owner")
        self.notify("group")

        self._update_size()

    def stop(self):
        self._inner_worker.stop()


class PortfolioSendTrashWorker(GObject.GObject, CachedWorker):
    __gtype_name__ = "PortfolioSendTrashWorker"

    __gsignals__ = {
        "started": (GObject.SignalFlags.RUN_LAST, None, ()),
        "pre-update": (GObject.SignalFlags.RUN_LAST, None, (str,)),
        "updated": (GObject.SignalFlags.RUN_LAST, None, (str, object, int, int)),
        "finished": (GObject.SignalFlags.RUN_LAST, None, (int,)),
        "failed": (GObject.SignalFlags.RUN_LAST, None, (str,)),
        "stopped": (GObject.SignalFlags.RUN_LAST, None, ()),
    }

    def __init__(self, selection):
        super().__init__()
        CachedWorker.__init__(self)
        self._selection = selection
        self._is_flatpak = utils.is_flatpak()

    def start(self):
        self.emit("started")

        self._paths = [path for path, ref in self._selection]
        self._refs = dict(self._selection)

        self._total = len(self._paths)
        self._index = 0
        self._timeout_handler_id = GLib.idle_add(
            self.step, priority=GLib.PRIORITY_HIGH_IDLE + 20
        )

    def step(self):
        if self._index >= self._total:
            self.emit("finished", self._total)
            return

        path = self._paths[self._index]

        try:
            self.emit("pre-update", path)
            default_trash.trash(path)
        except Exception as e:
            logger.debug(e)
            self.emit("failed", path)
            return

        self._index += 1
        self.emit("updated", path, self._refs.get(path), self._index, self._total)
        self._timeout_handler_id = GLib.idle_add(
            self.step, priority=GLib.PRIORITY_HIGH_IDLE + 20
        )

    def stop(self):
        if self._timeout_handler_id is not None:
            GLib.Source.remove(self._timeout_handler_id)
            self._timeout_handler_id = None
        self.emit("stopped")


class PortfolioRestoreTrashWorker(GObject.GObject, CachedWorker):
    __gtype_name__ = "PortfolioRestoreTrashWorker"

    __gsignals__ = {
        "started": (GObject.SignalFlags.RUN_LAST, None, ()),
        "pre-update": (GObject.SignalFlags.RUN_LAST, None, (str,)),
        "updated": (GObject.SignalFlags.RUN_LAST, None, (str, object, int, int)),
        "finished": (GObject.SignalFlags.RUN_LAST, None, (int,)),
        "failed": (GObject.SignalFlags.RUN_LAST, None, (str,)),
        "stopped": (GObject.SignalFlags.RUN_LAST, None, ()),
    }

    def __init__(self, selection):
        super().__init__()
        CachedWorker.__init__(self)
        self._selection = selection

    def start(self):
        self.emit("started")

        self._paths = [path for path, ref in self._selection]
        self._refs = dict(self._selection)

        self._total = len(self._paths)
        self._index = 0
        self._timeout_handler_id = GLib.idle_add(
            self.step, priority=GLib.PRIORITY_HIGH_IDLE + 20
        )

    def step(self):
        if self._index >= self._total:
            self.emit("finished", self._total)
            return

        path = self._paths[self._index]

        try:
            self.emit("pre-update", path)
            default_trash.restore(path)
        except Exception as e:
            logger.debug(e)
            self.emit("failed", path)
            return

        self._index += 1
        self.emit("updated", path, self._refs.get(path), self._index, self._total)
        self._timeout_handler_id = GLib.idle_add(
            self.step, priority=GLib.PRIORITY_HIGH_IDLE + 20
        )

    def stop(self):
        if self._timeout_handler_id is not None:
            GLib.Source.remove(self._timeout_handler_id)
            self._timeout_handler_id = None
        self.emit("stopped")


class PortfolioDeleteTrashWorker(GObject.GObject):
    __gtype_name__ = "PortfolioDeleteTrashWorker"

    __gsignals__ = {
        "started": (GObject.SignalFlags.RUN_LAST, None, ()),
        "pre-update": (GObject.SignalFlags.RUN_LAST, None, (str,)),
        "updated": (GObject.SignalFlags.RUN_LAST, None, (str, object, int, int)),
        "finished": (GObject.SignalFlags.RUN_LAST, None, (int,)),
        "failed": (GObject.SignalFlags.RUN_LAST, None, (str,)),
        "stopped": (GObject.SignalFlags.RUN_LAST, None, ()),
    }

    def __init__(self, selection):
        super().__init__()
        self._selection = selection

    def start(self):
        self.emit("started")

        self._paths = [path for path, ref in self._selection]
        self._refs = dict(self._selection)

        self._total = len(self._paths)
        self._index = 0
        self._timeout_handler_id = GLib.idle_add(
            self.step, priority=GLib.PRIORITY_HIGH_IDLE + 20
        )

    def step(self):
        if self._index >= self._total:
            self.emit("finished", self._total)
            return

        path = self._paths[self._index]

        try:
            self.emit("pre-update", path)
            default_trash.remove(path)
        except Exception as e:
            logger.debug(e)
            self.emit("failed", path)
            return

        self._index += 1
        self.emit("updated", path, self._refs.get(path), self._index, self._total)
        self._timeout_handler_id = GLib.idle_add(
            self.step, priority=GLib.PRIORITY_HIGH_IDLE + 20
        )

    def stop(self):
        if self._timeout_handler_id is not None:
            GLib.Source.remove(self._timeout_handler_id)
            self._timeout_handler_id = None
        self.emit("stopped")


class PortfolioLoadTrashWorker(GObject.GObject, CachedWorker):
    __gtype_name__ = "PortfolioLoadTrashWorker"

    __gsignals__ = {
        "started": (GObject.SignalFlags.RUN_LAST, None, (str,)),
        "updated": (GObject.SignalFlags.RUN_LAST, None, (str, object, int, int)),
        "finished": (GObject.SignalFlags.RUN_LAST, None, (str,)),
        "failed": (GObject.SignalFlags.RUN_LAST, None, (str,)),
    }

    def __init__(self, directory=None, hidden=False):
        super().__init__()
        CachedWorker.__init__(self)
        self._timeout_handler_id = None

    def start(self):
        self.emit("started", "")

        try:
            self._paths = default_trash.list()
        except Exception as e:
            logger.debug(e)
            self.emit("failed", "")
            return

        self._total = len(self._paths)
        self._index = 0
        self._timeout_handler_id = GLib.idle_add(
            self.step, priority=GLib.PRIORITY_HIGH_IDLE + 20
        )

    def step(self):
        if self._index >= self._total:
            self.emit("finished", "")
            return

        path = self._paths[self._index]
        name = os.path.basename(path)
        icon = utils.get_file_icon(path)

        self._index += 1
        self.emit("updated", "", [(name, path, icon)], self._index, self._total)
        self._timeout_handler_id = GLib.idle_add(
            self.step, priority=GLib.PRIORITY_HIGH_IDLE + 20
        )

    def stop(self):
        if self._timeout_handler_id is None:
            return
        GLib.Source.remove(self._timeout_handler_id)
        self._timeout_handler_id = None
