# Copyright (C) 2015-2024 Damon Lynch <damonlynch@gmail.com>

# This file is part of Rapid Photo Downloader.
#
# Rapid Photo Downloader 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.
#
# Rapid Photo Downloader 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 Rapid Photo Downloader.  If not,
# see <http://www.gnu.org/licenses/>.

"""
Handle interprocess communication using 0MQ
"""


__author__ = "Damon Lynch"
__copyright__ = "Copyright 2015-2024, Damon Lynch"

import argparse
import logging
import os
import pickle
import shlex
import sys
import time
from collections import deque
from collections.abc import Sequence
from typing import Any

import psutil
import zmq
import zmq.log.handlers
from PyQt5.QtCore import QObject, pyqtSignal, pyqtSlot
from PyQt5.QtGui import QImage, QPixmap

if zmq.pyzmq_version_info()[0] < 17:
    from zmq.eventloop import ioloop
else:
    try:
        from tornado import ioloop
    except ImportError:
        from zmq.eventloop import ioloop  # note: deprecated in pyzmq 17.0.0

from zmq.eventloop.zmqstream import ZMQStream

from raphodo.constants import (
    BackupStatus,
    CameraErrorCode,
    ExtractionProcessing,
    ExtractionTask,
    RenameAndMoveStatus,
)
from raphodo.devices import Device
from raphodo.folderspreview import FoldersPreview
from raphodo.iplogging import ZeroMQSocketHandler
from raphodo.problemnotification import (
    BackingUpProblems,
    CopyingProblems,
    RenamingProblems,
    ScanProblems,
)
from raphodo.proximity import TemporalProximityGroups
from raphodo.rpdfile import FileSizeSum, FileTypeCounter, Photo, RPDFile, Video
from raphodo.storage.storage import StorageSpace
from raphodo.ui.viewutils import ThumbnailDataForProximity
from raphodo.utilities import CacheDirs, set_pdeathsig

logger = logging.getLogger()


def make_filter_from_worker_id(worker_id: int| str) -> bytes:
    r"""
    Returns a python byte string from an integer or string

    >>> make_filter_from_worker_id(54)
    b'54'

    >>> make_filter_from_worker_id('54')
    b'54'
    """
    if isinstance(worker_id, int):
        return str(worker_id).encode()
    if isinstance(worker_id, str):
        return worker_id.encode()
    raise (TypeError)


def create_identity(worker_type: str, identity: str) -> bytes:
    r"""Generate identity for a worker's 0mq socket.

    >>> create_identity('Worker', '1')
    b'Worker-1'
    >>> create_identity('Thumbnail Extractor', '2')
    b'Thumbnail-Extractor-2'
    >>> create_identity('Thumbnail Extractor Plus', '22 2')
    b'Thumbnail-Extractor-Plus-22-2'
    """

    # Replace any whitespace in the strings with a hyphen
    return "{}-{}".format(
        "-".join(worker_type.split()), "-".join(identity.split())
    ).encode()


def get_worker_id_from_identity(identity: bytes) -> int:
    r"""Extract worker id from the identity used in a 0mq socket

    >>> get_worker_id_from_identity(b'Worker-1')
    1
    >>> get_worker_id_from_identity(b'Thumbnail-Extractor-2')
    2
    >>> get_worker_id_from_identity(b'Thumbnail-Extractor-Plus-22-2')
    2
    """
    return int(identity.decode().split("-")[-1])


def create_inproc_msg(
    cmd: bytes, worker_id: int|None = None, data: Any | None = None
) -> list[bytes]:
    """
    Create a list of three values to be sent via a PAIR socket
    between main and child threads using 0MQ.
    """

    worker_id = make_filter_from_worker_id(worker_id) if worker_id is not None else b""

    data = b"" if data is None else pickle.dumps(data, pickle.HIGHEST_PROTOCOL)

    return [cmd, worker_id, data]


class ThreadNames:
    rename = "rename"
    scan = "scan"
    copy = "copy"
    backup = "backup"
    thumbnail_daemon = "thumbnail_daemon"
    thumbnailer = "thumbnailer"
    offload = "offload"
    logger = "logger"
    load_balancer = "load_balancer"
    new_version = "new_version"


class ProcessManager:
    def __init__(self, logging_port: int, thread_name: str) -> None:

        super().__init__()

        self.logging_port = logging_port

        self.processes = {}  # type: dict[int, psutil.Process]
        self._process_to_run = ""  # Implement in subclass

        self.thread_name = thread_name

        # Monitor which workers we have running
        self.workers = []  # type: list[int]

    def _get_cmd(self) -> str:
        return "{} {}".format(
            sys.executable,
            os.path.join(
                os.path.abspath(os.path.dirname(__file__)), self._process_to_run
            ),
        )

    def _get_command_line(self, worker_id: int) -> str:
        """
        Implement in subclass
        """
        return ""

    def add_worker(self, worker_id: int) -> None:

        command_line = self._get_command_line(worker_id)
        args = shlex.split(command_line)

        # run command immediately, without waiting a reply, and instruct the Linux
        # kernel to send a terminate signal should this process unexpectedly die
        try:
            proc = psutil.Popen(args, preexec_fn=set_pdeathsig())
        except OSError as e:
            logging.critical("Failed to start process: %s", command_line)
            logging.critical("OSError [Errno %s]: %s", e.errno, e.strerror)
            if e.errno == 8:
                logging.critical(
                    "Script shebang line might be malformed or missing: %s",
                    self._get_cmd(),
                )
            sys.exit(1)
        logging.debug("Started '%s' with pid %s", command_line, proc.pid)

        # Add to list of running workers
        self.workers.append(worker_id)
        self.processes[worker_id] = proc

    def forcefully_terminate(self) -> None:
        """
        Forcefully terminate any running child processes.
        """

        zombie_processes = [
            p
            for p in self.processes.values()
            if p.is_running() and p.status() == psutil.STATUS_ZOMBIE
        ]
        running_processes = [
            p
            for p in self.processes.values()
            if p.is_running() and p.status() != psutil.STATUS_ZOMBIE
        ]
        if hasattr(self, "_process_name"):
            logging.debug(
                "Forcefully terminating processes for %s: %s zombies, %s running.",
                self._process_name,
                len(zombie_processes),
                len(running_processes),
            )

        for p in zombie_processes:  # type: psutil.Process
            try:
                logging.debug("Killing zombie process %s with pid %s", p.name(), p.pid)
                p.kill()
            except Exception:
                logging.error("Failed to kill process with pid %s", p.pid)
        for p in running_processes:  # type: psutil.Process
            try:
                logging.debug("Terminating process %s with pid %s", p.name(), p.pid)
                p.terminate()
            except Exception:
                logging.error("Terminating process with pid %s failed", p.pid)
        gone, alive = psutil.wait_procs(running_processes, timeout=2)
        for p in alive:
            try:
                logging.debug("Killing zombie process %s with pid %s", p.name(), p.pid)
                p.kill()
            except Exception:
                logging.error("Failed to kill process with pid %s", p.pid)

    def process_alive(self, worker_id: int) -> bool:
        """
        Process IDs are reused by the system. Check to make sure
        a new process has not been created with the same process id.

        :param worker_id: the process to check
        :return True if the process is the same, False otherwise
        """

        return self.processes[worker_id].is_running()


class PullPipelineManager(ProcessManager, QObject):
    """
    Base class from which more specialized 0MQ classes are derived.

    Receives data into its sink via a ZMQ PULL socket, but does not
    specify how workers should be sent data.

    Outputs signals using Qt.
    """

    message = pyqtSignal(str)  # Derived class will change this
    sinkStarted = pyqtSignal()
    workerFinished = pyqtSignal(int)
    workerStopped = pyqtSignal(int)
    receiverPortSignal = pyqtSignal(int)

    def __init__(self, logging_port: int, thread_name: str) -> None:
        super().__init__(logging_port=logging_port, thread_name=thread_name)

    def _start_sockets(self) -> None:

        context = zmq.Context.instance()

        # Subclasses must define the type of port they need to send messages
        self.ventilator_socket = None
        self.ventilator_port = None

        # Sink socket to receive results of the workers
        self.receiver_socket = context.socket(zmq.PULL)
        self.receiver_port = self.receiver_socket.bind_to_random_port("tcp://*")

        # Socket to communicate directly with the sink, bypassing the workers
        self.terminate_socket = context.socket(zmq.PUSH)
        self.terminate_socket.connect(f"tcp://localhost:{self.receiver_port}")

        # Socket to receive commands from the main thread
        self.thread_controller = context.socket(zmq.PAIR)
        self.thread_controller.connect(f"inproc://{self.thread_name}")

        self.terminating = False

    @pyqtSlot()
    def run_sink(self) -> None:
        logging.debug("Running sink for %s", self._process_name)

        self._start_sockets()

        poller = zmq.Poller()
        poller.register(self.receiver_socket, zmq.POLLIN)
        poller.register(self.thread_controller, zmq.POLLIN)

        self.receiverPortSignal.emit(self.receiver_port)
        self.sinkStarted.emit()

        while True:
            try:
                socks = dict(poller.poll())
            except KeyboardInterrupt:
                break
            if self.receiver_socket in socks:
                # Receive messages from the workers
                # (or the terminate socket)
                worker_id, directive, content = self.receiver_socket.recv_multipart()

                if directive == b"cmd":
                    command = content
                    assert command in (b"STOPPED", b"FINISHED", b"KILL")
                    if command == b"KILL":
                        # Terminate immediately, without regard for any
                        # incoming messages. This message is only sent
                        # from this manager to itself, using the
                        # self.terminate_socket
                        logging.debug(f"{self._process_name} is terminating")
                        break
                    # This worker is done; remove from monitored workers and
                    # continue
                    worker_id = int(worker_id)
                    if command == b"STOPPED":
                        logging.debug(
                            "%s worker %s has stopped", self._process_name, worker_id
                        )
                        self.workerStopped.emit(worker_id)
                    else:
                        # Worker has finished its work
                        self.workerFinished.emit(worker_id)
                    self.workers.remove(worker_id)
                    del self.processes[worker_id]
                    if not self.workers:
                        logging.debug(
                            f"{self._process_name} currently has no workers"
                        )
                    if not self.workers and self.terminating:
                        logging.debug(f"{self._process_name} is exiting")
                        break
                else:
                    assert directive == b"data"
                    self.content = content
                    self.process_sink_data()

            if self.thread_controller in socks:
                # Receive messages from the main Rapid Photo Downloader thread
                self.process_thread_directive()

    def process_thread_directive(self) -> None:
        directive, worker_id, data = self.thread_controller.recv_multipart()

        # Directives: START, STOP, TERMINATE, SEND_TO_WORKER, STOP_WORKER, START_WORKER
        if directive == b"START":
            self.start()
        elif directive == b"START_WORKER":
            self.start_worker(worker_id=worker_id, data=data)
        elif directive == b"SEND_TO_WORKER":
            self.send_message_to_worker(worker_id=worker_id, data=data)
        elif directive == b"STOP":
            self.stop()
        elif directive == b"STOP_WORKER":
            self.stop_worker(worker_id=worker_id)
        elif directive == b"PAUSE":
            self.pause()
        elif directive == b"RESUME":
            self.resume(worker_id=worker_id)
        elif directive == b"TERMINATE":
            self.forcefully_terminate()
        else:
            logging.critical("%s received unknown directive %s", directive.decode())

    def process_sink_data(self) -> None:
        data = pickle.loads(self.content)
        self.message.emit(data)

    def terminate_sink(self) -> None:
        self.terminate_socket.send_multipart([b"0", b"cmd", b"KILL"])

    def _get_ventilator_start_message(self, worker_id: bytes) -> list:
        return [worker_id, b"cmd", b"START"]

    def start(self) -> None:
        logging.critical(
            "Member function start() not implemented in child class of %s",
            self._process_name,
        )

    def start_worker(self, worker_id: bytes, data: bytes) -> None:
        logging.critical(
            "Member function start_worker() not implemented in child class of %s",
            self._process_name,
        )

    def stop(self) -> None:
        logging.critical(
            "Member function stop() not implemented in child class of %s",
            self._process_name,
        )

    def stop_worker(self, worker_id: int) -> None:
        logging.critical(
            "Member function stop_worker() not implemented in child class of %s",
            self._process_name,
        )

    def pause(self) -> None:
        logging.critical(
            "Member function pause() not implemented in child class of %s",
            self._process_name,
        )

    def resume(self, worker_id: bytes|None) -> None:
        logging.critical(
            "Member function stop_worker() not implemented in child class of %s",
            self._process_name,
        )

    def send_message_to_worker(
        self, data: bytes, worker_id: bytes|None = None
    ) -> None:
        if self.terminating:
            logging.debug(
                "%s not sending message to worker because manager is terminated",
                self._process_name,
            )
            return
        if not self.workers:
            logging.debug(
                "%s not sending message to worker because there are no workers",
                self._process_name,
            )
            return

        assert isinstance(data, bytes)

        message = [worker_id, b"data", data] if worker_id else [b"data", data]
        self.ventilator_socket.send_multipart(message)

    def forcefully_terminate(self) -> None:
        """
        Forcefully terminate any child processes and clean up.

        Shuts down the sink too.
        """

        super().forcefully_terminate()
        self.terminate_sink()


class LoadBalancerWorkerManager(ProcessManager):
    def __init__(
        self, no_workers: int, backend_port: int, sink_port: int, logging_port: int
    ) -> None:
        super().__init__(logging_port=logging_port, thread_name="")
        self.no_workers = no_workers
        self.backend_port = backend_port
        self.sink_port = sink_port

    def _get_command_line(self, worker_id: int) -> str:
        cmd = self._get_cmd()

        return "{} --request {} --send {} --identity {} --logging {}".format(
            cmd, self.backend_port, self.sink_port, worker_id, self.logging_port
        )

    def start_workers(self) -> None:
        for worker_id in range(self.no_workers):
            self.add_worker(worker_id)

    def zombie_workers(self) -> list[int]:
        return [
            worker_id
            for worker_id in self.workers
            if self.processes[worker_id].status() == psutil.STATUS_ZOMBIE
        ]


class LRUQueue:
    """LRUQueue class using ZMQStream/IOLoop for event dispatching"""

    def __init__(
        self,
        backend_socket: zmq.Socket,
        frontend_socket: zmq.Socket,
        controller_socket: zmq.Socket,
        worker_type: str,
        process_manager: LoadBalancerWorkerManager,
    ) -> None:

        self.worker_type = worker_type
        self.process_manager = process_manager
        self.workers = deque()
        self.terminating = False
        self.terminating_workers = set()  # type: set[bytes]
        self.stopped_workers = set()  # type: set[int]

        self.backend = ZMQStream(backend_socket)
        self.frontend = ZMQStream(frontend_socket)
        self.controller = ZMQStream(controller_socket)
        self.backend.on_recv(self.handle_backend)
        self.controller.on_recv(self.handle_controller)

        self.loop = ioloop.IOLoop.instance()

    def handle_controller(self, msg):
        self.terminating = True

        while len(self.workers):
            worker_identity = self.workers.popleft()

            logging.debug(
                "%s load balancer sending stop cmd to worker %s",
                self.worker_type,
                worker_identity.decode(),
            )
            self.backend.send_multipart([worker_identity, b"", b"cmd", b"STOP"])
            self.terminating_workers.add(worker_identity)

        self.loop.add_timeout(time.time() + 3, self.loop.stop)

    def handle_backend(self, msg):
        # Queue worker address for LRU routing
        worker_identity, empty, client_addr = msg[:3]

        # add worker back to the list of workers
        self.workers.append(worker_identity)

        zw = self.process_manager.zombie_workers()
        if zw:
            logging.critical("%s dead thumbnail extractors", len(zw))

        # Second frame is empty
        assert empty == b""

        if msg[-1] == b"STOPPED" and self.terminating:
            worker_id = get_worker_id_from_identity(worker_identity)
            self.stopped_workers.add(worker_id)
            self.terminating_workers.remove(worker_identity)
            if len(self.terminating_workers) == 0:
                for worker_id in self.stopped_workers:
                    p = self.process_manager.processes[
                        worker_id
                    ]  # type: psutil.Process
                    if p.is_running():
                        pid = p.pid
                        if p.status() != psutil.STATUS_SLEEPING:
                            logging.debug(
                                "Waiting on %s process %s...", p.status(), pid
                            )
                            os.waitpid(pid, 0)
                            logging.debug("...process %s is finished", pid)
                        else:
                            logging.debug("Process %s is sleeping", pid)
                self.loop.add_timeout(time.time() + 0.5, self.loop.stop)

        if len(self.workers) == 1:
            # on first recv, start accepting frontend messages
            self.frontend.on_recv(self.handle_frontend)

    def handle_frontend(self, request):
        #  Dequeue and drop the next worker address
        worker_identity = self.workers.popleft()

        message = [worker_identity, b""] + request
        self.backend.send_multipart(message)
        if len(self.workers) == 0:
            # stop receiving until workers become available again
            self.frontend.stop_on_recv()


class LoadBalancer:
    def __init__(self, worker_type: str, process_manager) -> None:

        self.parser = argparse.ArgumentParser()
        self.parser.add_argument("--receive", required=True)
        self.parser.add_argument("--send", required=True)
        self.parser.add_argument("--controller", required=True)
        self.parser.add_argument("--logging", required=True)

        args = self.parser.parse_args()
        self.controller_port = args.controller

        context = zmq.Context()
        frontend = context.socket(zmq.PULL)
        frontend_port = frontend.bind_to_random_port("tcp://*")

        backend = context.socket(zmq.ROUTER)
        backend_port = backend.bind_to_random_port("tcp://*")

        reply = context.socket(zmq.REP)
        reply.connect(f"tcp://localhost:{args.receive}")

        controller = context.socket(zmq.PULL)
        controller.connect(f"tcp://localhost:{self.controller_port}")

        sink_port = args.send
        logging_port = args.logging

        self.logger_publisher = ProcessLoggerPublisher(
            context=context, name=worker_type, notification_port=args.logging
        )

        logging.debug(
            f"{worker_type} load balancer waiting to be notified how many workers to "
            "initialize..."
        )
        no_workers = int(reply.recv())
        logging.debug(
            f"...{worker_type} load balancer will use {no_workers} workers"
        )
        reply.send(str(frontend_port).encode())

        process_manager = process_manager(
            no_workers, backend_port, sink_port, logging_port
        )
        process_manager.start_workers()

        # create queue with the sockets
        queue = LRUQueue(backend, frontend, controller, worker_type, process_manager)  # noqa: F841

        # start reactor, which is an infinite loop
        ioloop.IOLoop.instance().start()

        # Finished infinite loop: do some housekeeping
        logging.debug("Forcefully terminating load balancer child processes")
        process_manager.forcefully_terminate()

        frontend.close()
        backend.close()


class LoadBalancerManager(ProcessManager, QObject):
    """
    Launches and requests termination of the Load Balancer process
    """

    load_balancer_started = pyqtSignal(int)

    def __init__(
        self,
        context: zmq.Context,
        no_workers: int,
        sink_port: int,
        logging_port: int,
        thread_name: str,
    ) -> None:
        super().__init__(logging_port=logging_port, thread_name=thread_name)
        self.no_workers = no_workers
        self.sink_port = sink_port
        self.context = context

    @pyqtSlot()
    def start_load_balancer(self) -> None:

        self.controller_socket = self.context.socket(zmq.PUSH)
        self.controller_port = self.controller_socket.bind_to_random_port("tcp://*")

        self.requester = self.context.socket(zmq.REQ)
        self.requester_port = self.requester.bind_to_random_port("tcp://*")

        self.thread_controller = self.context.socket(zmq.PAIR)
        self.thread_controller.connect(f"inproc://{self.thread_name}")

        worker_id = 0
        self.add_worker(worker_id)
        self.requester.send(str(self.no_workers).encode())
        self.frontend_port = int(self.requester.recv())
        self.load_balancer_started.emit(self.frontend_port)

        # wait for stop signal
        directive, worker_id, data = self.thread_controller.recv_multipart()
        assert directive == b"STOP"
        self.stop()

    def stop(self):
        self.controller_socket.send(b"STOP")

    def _get_command_line(self, worker_id: int) -> str:
        cmd = self._get_cmd()

        return "{} --receive {} --send {} --controller {} --logging {}".format(
            cmd,
            self.requester_port,
            self.sink_port,
            self.controller_port,
            self.logging_port,
        )


DAEMON_WORKER_ID = 0


class PushPullDaemonManager(PullPipelineManager):
    """
    Manage a single instance daemon worker process that waits to work on data
    issued by this manager. The data to be worked on is issued in sequence,
    one after the other.

    Because this is a single daemon process, a Push-Pull model is most
    suitable for sending the data.
    """

    def _start_sockets(self) -> None:

        super()._start_sockets()

        context = zmq.Context.instance()

        # Ventilator socket to send message to worker
        self.ventilator_socket = context.socket(zmq.PUSH)
        self.ventilator_port = self.ventilator_socket.bind_to_random_port("tcp://*")

    def stop(self) -> None:
        """
        Permanently stop the daemon process and terminate
        """

        logging.debug(f"{self._process_name} halting")
        self.terminating = True

        # Only send stop command if the process is still running
        if self.process_alive(DAEMON_WORKER_ID):
            try:
                self.ventilator_socket.send_multipart([b"cmd", b"STOP"], zmq.DONTWAIT)
            except zmq.Again:
                logging.debug(
                    "Terminating %s sink because child process did not receive message",
                    self._process_name,
                )
                self.terminate_sink()
        else:
            # The process may have crashed. Stop the sink.
            self.terminate_sink()

    def _get_command_line(self, worker_id: int) -> str:
        cmd = self._get_cmd()

        return "{} --receive {} --send {} --logging {}".format(
            cmd, self.ventilator_port, self.receiver_port, self.logging_port
        )

    def _get_ventilator_start_message(self, worker_id: int) -> list[bytes]:
        return [b"cmd", b"START"]

    def start(self) -> None:
        logging.debug("Starting worker for %s", self._process_name)
        self.add_worker(worker_id=DAEMON_WORKER_ID)


class PublishPullPipelineManager(PullPipelineManager):
    """
    Manage a collection of worker processes that wait to work on data
    issued by this manager. The data to be worked on is issued in sequence,
    one after the other, either once, or many times.

    Because there are multiple worker process, a Publish-Subscribe model is
    most suitable for sending data to workers.
    """

    def _start_sockets(self) -> None:

        super()._start_sockets()

        context = zmq.Context.instance()

        # Ventilator socket to send messages to workers on
        self.ventilator_socket = context.socket(zmq.PUB)
        self.ventilator_port = self.ventilator_socket.bind_to_random_port("tcp://*")

        # Socket to synchronize the start of each worker
        self.sync_service_socket = context.socket(zmq.REP)
        self.sync_service_port = self.sync_service_socket.bind_to_random_port("tcp://*")

        # Socket for worker control: pause, resume, stop
        self.controller_socket = context.socket(zmq.PUB)
        self.controller_port = self.controller_socket.bind_to_random_port("tcp://*")

    def stop(self) -> None:
        """
        Permanently stop all the workers and terminate
        """

        logging.debug(f"{self._process_name} halting")
        self.terminating = True
        if self.workers:
            # Signal workers they must immediately stop
            termination_signal_sent = False
            alive_workers = [
                worker_id for worker_id in self.workers if self.process_alive(worker_id)
            ]
            for worker_id in alive_workers:

                message = [make_filter_from_worker_id(worker_id), b"STOP"]
                self.controller_socket.send_multipart(message)

                message = [make_filter_from_worker_id(worker_id), b"cmd", b"STOP"]
                self.ventilator_socket.send_multipart(message)
                termination_signal_sent = True

            if not termination_signal_sent:
                self.terminate_sink()
        else:
            self.terminate_sink()

    def stop_worker(self, worker_id: bytes) -> None:
        """
        Permanently stop one worker
        """

        if int(worker_id) in self.workers:
            message = [worker_id, b"STOP"]
            self.controller_socket.send_multipart(message)
            message = [worker_id, b"cmd", b"STOP"]
            self.ventilator_socket.send_multipart(message)

    def start_worker(self, worker_id: bytes, data: bytes) -> None:

        self.add_worker(int(worker_id))

        # Send START commands until scan worker indicates it is ready to
        # receive data
        # Worker ID must be in bytes format
        while True:
            self.ventilator_socket.send_multipart(
                self._get_ventilator_start_message(worker_id)
            )
            try:
                # look for synchronization request
                self.sync_service_socket.recv(zmq.DONTWAIT)
                # send synchronization reply
                self.sync_service_socket.send(b"")
                break
            except zmq.Again:
                # Briefly pause sending out START messages
                # There is no point flooding the network
                time.sleep(0.01)

        # Send data to process to tell it what to work on
        self.send_message_to_worker(data=data, worker_id=worker_id)

    def _get_command_line(self, worker_id: int) -> str:
        cmd = self._get_cmd()

        return (
            "{} --receive {} --send {} --controller {} --syncclient {} "
            "--filter {} --logging "
            "{}".format(
                cmd,
                self.ventilator_port,
                self.receiver_port,
                self.controller_port,
                self.sync_service_port,
                worker_id,
                self.logging_port,
            )
        )

    def __len__(self) -> int:
        return len(self.workers)

    def __contains__(self, item) -> bool:
        return item in self.workers

    def pause(self) -> None:
        for worker_id in self.workers:
            message = [make_filter_from_worker_id(worker_id), b"PAUSE"]
            self.controller_socket.send_multipart(message)

    def resume(self, worker_id: bytes) -> None:
        workers = [int(worker_id)] if worker_id else self.workers
        for worker_id in workers:
            message = [make_filter_from_worker_id(worker_id), b"RESUME"]
            self.controller_socket.send_multipart(message)


class ProcessLoggerPublisher:
    """
    Setup the sockets for worker processes to send log messages to the
    main process.

    Two tasks: set up the PUB socket, and then tell the main process
    what port we're using via a second socket, and when we're closing it.
    """

    def __init__(self, context: zmq.Context, name: str, notification_port: int) -> None:

        self.logger_pub = context.socket(zmq.PUB)
        self.logger_pub_port = self.logger_pub.bind_to_random_port("tcp://*")
        self.handler = ZeroMQSocketHandler(self.logger_pub)
        self.handler.setLevel(logging.DEBUG)

        self.logger = logging.getLogger()
        self.logger.setLevel(logging.DEBUG)
        self.logger.addHandler(self.handler)

        self.logger_socket = context.socket(zmq.PUSH)
        self.logger_socket.connect(f"tcp://localhost:{notification_port}")
        self.logger_socket.send_multipart(
            [b"CONNECT", str(self.logger_pub_port).encode()]
        )

    def close(self):
        self.logger.removeHandler(self.handler)
        self.logger_socket.send_multipart(
            [b"DISCONNECT", str(self.logger_pub_port).encode()]
        )
        self.logger_pub.close()
        self.logger_socket.close()


class WorkerProcess:
    def __init__(self, worker_type: str) -> None:
        super().__init__()
        self.parser = argparse.ArgumentParser()
        self.parser.add_argument("--receive", required=True)
        self.parser.add_argument("--send", required=True)
        self.parser.add_argument("--logging", required=True)

    def cleanup_pre_stop(self) -> None:
        """
        Operations to run if process is stopped.

        Implement in child class if needed.
        """

        pass

    def setup_logging_pub(self, notification_port: int, name: str) -> None:
        """
        Sets up the 0MQ socket that sends out logging messages

        :param notification_port: port that should be notified about
         the new logging publisher
        :param name: descriptive name to place in the log messages
        """

        if self.worker_id is not None:
            name = f"{name}-{self.worker_id.decode()}"
        self.logger_publisher = ProcessLoggerPublisher(
            context=self.context, name=name, notification_port=notification_port
        )

    def send_message_to_sink(self) -> None:

        self.sender.send_multipart([self.worker_id, b"data", self.content])

    def initialise_process(self) -> None:
        # Wait to receive "START" message
        worker_id, directive, content = self.receiver.recv_multipart()
        assert directive == b"cmd"
        assert content == b"START"

        # send a synchronization request
        self.sync_client.send(b"")

        # wait for synchronization reply
        self.sync_client.recv()

        # Receive next "START" message and discard, looking for data message
        while True:
            worker_id, directive, content = self.receiver.recv_multipart()
            if directive == b"data":
                break
            else:
                assert directive == b"cmd"
                assert content == b"START"

        self.content = content

    def do_work(self):
        pass


class DaemonProcess(WorkerProcess):
    """
    Single instance
    """

    def __init__(self, worker_type: str) -> None:
        super().__init__(worker_type)

        args = self.parser.parse_args()

        self.context = zmq.Context()
        # Socket to send messages along the pipe to
        self.sender = self.context.socket(zmq.PUSH)
        self.sender.set_hwm(10)
        self.sender.connect(f"tcp://localhost:{args.send}")

        self.receiver = self.context.socket(zmq.PULL)
        self.receiver.connect(f"tcp://localhost:{args.receive}")

        self.worker_id = None

        self.setup_logging_pub(notification_port=args.logging, name=worker_type)

    def run(self) -> None:
        pass

    def check_for_command(self, directive: bytes, content: bytes) -> None:
        if directive == b"cmd":
            assert content == b"STOP"
            self.cleanup_pre_stop()
            # signal to sink that we've terminated before finishing
            self.sender.send_multipart(
                [make_filter_from_worker_id(DAEMON_WORKER_ID), b"cmd", b"STOPPED"]
            )
            sys.exit(0)

    def send_message_to_sink(self) -> None:
        # Must use a dummy value for the worker id, as there is only ever one
        # instance.
        self.sender.send_multipart(
            [make_filter_from_worker_id(DAEMON_WORKER_ID), b"data", self.content]
        )


class WorkerInPublishPullPipeline(WorkerProcess):
    """
    Worker counterpart to PublishPullPipelineManager; multiple instance.
    """

    def __init__(self, worker_type: str) -> None:
        super().__init__(worker_type)
        self.add_args()

        args = self.parser.parse_args()

        subscription_filter = self.worker_id = args.filter.encode()
        self.context = zmq.Context()

        self.setup_sockets(args, subscription_filter)
        self.setup_logging_pub(notification_port=args.logging, name=worker_type)

        self.initialise_process()
        self.do_work()

    def add_args(self) -> None:
        self.parser.add_argument("--filter", required=True)
        self.parser.add_argument("--syncclient", required=True)
        self.parser.add_argument("--controller", required=True)

    def setup_sockets(self, args, subscription_filter: bytes) -> None:

        # Socket to send messages along the pipe to
        self.sender = self.context.socket(zmq.PUSH)
        self.sender.set_hwm(10)
        self.sender.connect(f"tcp://localhost:{args.send}")

        # Socket to receive messages from the pipe
        self.receiver = self.context.socket(zmq.SUB)
        self.receiver.connect(f"tcp://localhost:{args.receive}")
        self.receiver.setsockopt(zmq.SUBSCRIBE, subscription_filter)

        # Socket to receive controller messages: stop, pause, resume
        self.controller = self.context.socket(zmq.SUB)
        self.controller.connect(f"tcp://localhost:{args.controller}")
        self.controller.setsockopt(zmq.SUBSCRIBE, subscription_filter)

        # Socket to synchronize the start of receiving data from upstream
        self.sync_client = self.context.socket(zmq.REQ)
        self.sync_client.connect(f"tcp://localhost:{args.syncclient}")

    def check_for_command(self, directive: bytes, content) -> None:
        if directive == b"cmd":
            try:
                assert content == b"STOP"
            except AssertionError:
                logging.critical(
                    "Expected STOP command but instead got %s", content.decode()
                )
            else:
                self.cleanup_pre_stop()
                self.disconnect_logging()
                # signal to sink that we've terminated before finishing
                self.sender.send_multipart([self.worker_id, b"cmd", b"STOPPED"])
                sys.exit(0)

    def check_for_controller_directive(self) -> None:
        try:
            # Don't block if the process is running regularly
            # If there is no command, an exception will occur
            worker_id, command = self.controller.recv_multipart(zmq.DONTWAIT)
            assert command in [b"PAUSE", b"STOP"]
            assert worker_id == self.worker_id

            if command == b"PAUSE":
                # Because the process is paused, do a blocking read to
                # wait for the next command
                worker_id, command = self.controller.recv_multipart()
                assert command in [b"RESUME", b"STOP"]
            if command == b"STOP":
                self.cleanup_pre_stop()
                # before finishing, signal to sink that we've terminated
                self.sender.send_multipart([self.worker_id, b"cmd", b"STOPPED"])
                sys.exit(0)
        except zmq.Again:
            pass  # Continue working

    def resume_work(self) -> None:
        worker_id, command = self.controller.recv_multipart()
        assert command in [b"RESUME", b"STOP"]
        if command == b"STOP":
            self.cleanup_pre_stop()
            self.disconnect_logging()
            # before finishing, signal to sink that we've terminated
            self.sender.send_multipart([self.worker_id, b"cmd", b"STOPPED"])
            sys.exit(0)

    def disconnect_logging(self) -> None:
        self.logger_publisher.close()

    def send_finished_command(self) -> None:
        self.sender.send_multipart([self.worker_id, b"cmd", b"FINISHED"])


class LoadBalancerWorker:
    def __init__(self, worker_type: str) -> None:
        super().__init__()
        self.parser = argparse.ArgumentParser()
        self.parser.add_argument("--request", required=True)
        self.parser.add_argument("--send", required=True)
        self.parser.add_argument("--identity", required=True)
        self.parser.add_argument("--logging", required=True)

        args = self.parser.parse_args()

        self.context = zmq.Context()

        self.requester = self.context.socket(zmq.REQ)
        self.identity = create_identity(worker_type, args.identity)
        self.requester.identity = self.identity
        self.requester.connect(f"tcp://localhost:{args.request}")

        # Sender is located in the main process. It is where output (messages)
        # from this process are sent to.
        self.sender = self.context.socket(zmq.PUSH)
        self.sender.connect(f"tcp://localhost:{args.send}")

        self.logger_publisher = ProcessLoggerPublisher(
            context=self.context, name=worker_type, notification_port=args.logging
        )

        # Tell the load balancer we are ready for work
        self.requester.send(b"READY")
        self.do_work()

    def do_work(self) -> None:
        # Implement in subclass
        pass

    def cleanup_pre_stop(self) -> None:
        """
        Operations to run if the process is stopped.

        Implement in child class if needed.
        """

        pass

    def exit(self):
        self.cleanup_pre_stop()
        identity = self.requester.identity.decode()
        # signal to load balancer that we've terminated before finishing
        self.requester.send_multipart([b"", b"", b"STOPPED"])
        self.requester.close()
        self.sender.close()
        self.logger_publisher.close()
        self.context.term()
        logging.debug("%s with pid %s stopped", identity, os.getpid())
        sys.exit(0)

    def check_for_command(self, directive: bytes, content: bytes):
        if directive == b"cmd":
            assert content == b"STOP"
            self.exit()


class ProcessLoggingManager(QObject):
    """
    Receive and log logging messages from workers.

    An alternative might be using python logging's QueueListener, which
    like this code, runs on its own thread.
    """

    ready = pyqtSignal(int)

    @pyqtSlot()
    def startReceiver(self) -> None:
        context = zmq.Context.instance()
        self.receiver = context.socket(zmq.SUB)
        # Subscribe to all variates of logging messages
        self.receiver.setsockopt(zmq.SUBSCRIBE, b"")

        # Socket to receive subscription information, and the stop command
        info_socket = context.socket(zmq.PULL)
        self.info_port = info_socket.bind_to_random_port("tcp://*")

        poller = zmq.Poller()
        poller.register(self.receiver, zmq.POLLIN)
        poller.register(info_socket, zmq.POLLIN)

        self.ready.emit(self.info_port)

        while True:
            try:
                socks = dict(poller.poll())
            except KeyboardInterrupt:
                break

            if self.receiver in socks:
                message = self.receiver.recv()
                record = logging.makeLogRecord(pickle.loads(message))
                logger.handle(record)

            if info_socket in socks:
                directive, content = info_socket.recv_multipart()
                if directive == b"STOP":
                    break
                elif directive == b"CONNECT":
                    self.addSubscription(content)
                else:
                    assert directive == b"DISCONNECT"
                    self.removeSubscription(content)

    def addSubscription(self, port: bytes) -> None:
        try:
            port = int(port)
        except ValueError:
            logging.critical(
                "Incorrect port value in add logging subscription: %s", port
            )
        else:
            logging.debug("Subscribing to logging on port %s", port)
            self.receiver.connect(f"tcp://localhost:{port}")

    def removeSubscription(self, port: bytes):
        try:
            port = int(port)
        except ValueError:
            logging.critical(
                "Incorrect port value in remove logging subscription: %s", port
            )
        else:
            logging.debug("Unsubscribing to logging on port %s", port)
            self.receiver.disconnect(f"tcp://localhost:{port}")


def stop_process_logging_manager(info_port: int) -> None:
    """
    Stop ProcessLoggingManager thread

    :param info_port: the port number the manager uses
    """

    context = zmq.Context.instance()
    command = context.socket(zmq.PUSH)
    command.connect(f"tcp://localhost:{info_port}")
    command.send_multipart([b"STOP", b""])


class ScanArguments:
    """
    Pass arguments to the scan process
    """

    def __init__(
        self, device: Device, ignore_other_types: bool, log_gphoto2: bool
    ) -> None:
        """
        Pass arguments to the scan process

        :param device: the device to scan
        :param ignore_other_types: ignore file types like TIFF
        :param log_gphoto2: whether to generate detailed gphoto2 log
         messages
        """

        self.device = device
        self.ignore_other_types = ignore_other_types
        self.log_gphoto2 = log_gphoto2


class ScanResults:
    """
    Receive results from the scan process
    """

    def __init__(
        self,
        rpd_files: list[RPDFile] | None = None,
        file_type_counter: FileTypeCounter | None= None,
        file_size_sum: FileSizeSum | None= None,
        error_code: CameraErrorCode | None= None,
        error_message: str|None = None,
        scan_id: int|None = None,
        optimal_display_name: str|None = None,
        storage_space: list[StorageSpace] | None = None,
        storage_descriptions: list[str]|None = None,
        mount_point: str|None = None,
        is_apple_mobile: bool|None = False,
        sample_photo: Photo|None = None,
        sample_video: Video|None = None,
        problems: ScanProblems | None= None,
        fatal_error: bool|None = None,
        camera_removed: bool|None = None,
        entire_video_required: bool|None = None,
        entire_photo_required: bool|None = None,
    ) -> None:
        self.rpd_files = rpd_files
        self.file_type_counter = file_type_counter
        self.file_size_sum = file_size_sum
        self.error_code = error_code
        if error_code and error_message is None:
            self.error_message = ""
        else:
            self.error_message = error_message
        self.scan_id = scan_id
        self.optimal_display_name = optimal_display_name
        self.storage_space = storage_space
        self.storage_descriptions = storage_descriptions
        self.mount_point = mount_point
        self.is_apple_mobile = is_apple_mobile
        self.sample_photo = sample_photo
        self.sample_video = sample_video
        self.problems = problems
        self.fatal_error = fatal_error
        self.camera_removed = camera_removed
        self.entire_video_required = entire_video_required
        self.entire_photo_required = entire_photo_required


class CopyFilesArguments:
    """
    Pass arguments to the copyfiles process
    """

    def __init__(
        self,
        scan_id: int,
        device: Device,
        photo_download_folder: str,
        video_download_folder: str,
        files: list[RPDFile],
        verify_file: bool,
        generate_thumbnails: bool,
        log_gphoto2: bool,
    ) -> None:
        self.scan_id = scan_id
        self.device = device
        self.photo_download_folder = photo_download_folder
        self.video_download_folder = video_download_folder
        self.files = files
        self.generate_thumbnails = generate_thumbnails
        self.verify_file = verify_file
        self.log_gphoto2 = log_gphoto2


class CopyFilesResults:
    """
    Receive results from the copyfiles process
    """

    def __init__(
        self,
        scan_id: int|None = None,
        photo_temp_dir: str|None = None,
        video_temp_dir: str|None = None,
        total_downloaded: int|None = None,
        chunk_downloaded: int|None = None,
        copy_succeeded: bool|None = None,
        rpd_file: RPDFile | None = None,
        download_count: int|None = None,
        mdata_exceptions: tuple|None = None,
        problems: CopyingProblems | None = None,
        camera_removed: bool|None = None,
    ) -> None:
        """

        :param scan_id: scan id of the device the files are being
         downloaded from
        :param photo_temp_dir: temp directory path, used to copy
         photos into until they're renamed
        :param video_temp_dir: temp directory path, used to copy
         videos into until they're renamed
        :param total_downloaded: how many bytes in total have been
         downloaded
        :param chunk_downloaded: how many bytes were downloaded since
         the last message
        :param copy_succeeded: whether the copy was successful or not
        :param rpd_file: details of the file that was copied
        :param download_count: a running count of how many files
         have been copied. Used in download tracking.
        :param mdata_exceptions: details of errors setting file metadata
        :param problems: details of any problems encountered copying files,
         not including metedata write problems.
        """

        self.scan_id = scan_id

        self.photo_temp_dir = photo_temp_dir
        self.video_temp_dir = video_temp_dir

        self.total_downloaded = total_downloaded
        self.chunk_downloaded = chunk_downloaded

        self.copy_succeeded = copy_succeeded
        self.rpd_file = rpd_file
        self.download_count = download_count
        self.mdata_exceptions = mdata_exceptions
        self.problems = problems
        self.camera_removed = camera_removed


class ThumbnailDaemonData:
    """
    Pass arguments to the thumbnail daemon process.

    Occurs after a file is downloaded & renamed, and also
    after a file is backed up.
    """

    def __init__(
        self,
        frontend_port: int|None = None,
        rpd_file: RPDFile | None = None,
        write_fdo_thumbnail: bool|None = None,
        use_thumbnail_cache: bool|None = None,
        backup_full_file_names: list[str]|None = None,
        fdo_name: str|None = None,
        force_exiftool: bool|None = None,
    ) -> None:
        self.frontend_port = frontend_port
        self.rpd_file = rpd_file
        self.write_fdo_thumbnail = write_fdo_thumbnail
        self.use_thumbnail_cache = use_thumbnail_cache
        self.backup_full_file_names = backup_full_file_names
        self.fdo_name = fdo_name
        self.force_exiftool = force_exiftool


class RenameAndMoveFileData:
    """
    Pass arguments to the renameandmovefile process
    """

    def __init__(
        self,
        rpd_file: RPDFile = None,
        download_count: int = None,
        download_succeeded: bool = None,
        message: RenameAndMoveStatus = None,
    ) -> None:
        self.rpd_file = rpd_file
        self.download_count = download_count
        self.download_succeeded = download_succeeded
        self.message = message


class RenameAndMoveFileResults:
    def __init__(
        self,
        move_succeeded: bool = None,
        rpd_file: RPDFile = None,
        download_count: int = None,
        stored_sequence_no: int = None,
        downloads_today: list[str] = None,
        problems: RenamingProblems | None = None,
    ) -> None:
        self.move_succeeded = move_succeeded
        self.rpd_file = rpd_file
        self.download_count = download_count
        self.stored_sequence_no = stored_sequence_no
        self.downloads_today = downloads_today
        self.problems = problems


class OffloadData:
    def __init__(
        self,
        thumbnail_rows: Sequence[ThumbnailDataForProximity] | None = None,
        proximity_seconds: int = None,
        rpd_files: Sequence[RPDFile] | None = None,
        strip_characters: bool|None = None,
        folders_preview: FoldersPreview | None = None,
    ) -> None:
        self.thumbnail_rows = thumbnail_rows
        self.proximity_seconds = proximity_seconds
        self.rpd_files = rpd_files
        self.strip_characters = strip_characters
        self.folders_preview = folders_preview


class OffloadResults:
    def __init__(
        self,
        proximity_groups: TemporalProximityGroups | None = None,
        folders_preview: FoldersPreview | None = None,
    ) -> None:
        self.proximity_groups = proximity_groups
        self.folders_preview = folders_preview


class BackupArguments:
    """
    Pass start up data to the back up process
    """

    def __init__(self, path: str, device_name: str) -> None:
        self.path = path
        self.device_name = device_name


class BackupFileData:
    """
    Pass file data to the backup process
    """

    def __init__(
        self,
        rpd_file: RPDFile | None = None,
        move_succeeded: bool|None = None,
        do_backup: bool|None = None,
        path_suffix: str|None = None,
        backup_duplicate_overwrite: bool|None = None,
        verify_file: bool|None = None,
        download_count: int|None = None,
        save_fdo_thumbnail: int|None = None,
        message: BackupStatus | None = None,
    ) -> None:
        self.rpd_file = rpd_file
        self.move_succeeded = move_succeeded
        self.do_backup = do_backup
        self.path_suffix = path_suffix
        self.backup_duplicate_overwrite = backup_duplicate_overwrite
        self.verify_file = verify_file
        self.download_count = download_count
        self.save_fdo_thumbnail = save_fdo_thumbnail
        self.message = message


class BackupResults:
    def __init__(
        self,
        scan_id: int,
        device_id: int,
        total_downloaded: int|None = None,
        chunk_downloaded: int|None = None,
        backup_succeeded: bool|None = None,
        do_backup: bool|None = None,
        rpd_file: RPDFile | None = None,
        backup_full_file_name: str|None = None,
        mdata_exceptions: tuple|None = None,
        problems: BackingUpProblems | None = None,
    ) -> None:
        self.scan_id = scan_id
        self.device_id = device_id
        self.total_downloaded = total_downloaded
        self.chunk_downloaded = chunk_downloaded
        self.backup_succeeded = backup_succeeded
        self.do_backup = do_backup
        self.rpd_file = rpd_file
        self.backup_full_file_name = backup_full_file_name
        self.mdata_exceptions = mdata_exceptions
        self.problems = problems


class GenerateThumbnailsArguments:
    def __init__(
        self,
        scan_id: int,
        rpd_files: list[RPDFile],
        name: str,
        proximity_seconds: int,
        cache_dirs: CacheDirs,
        need_photo_cache_dir: bool,
        need_video_cache_dir: bool,
        frontend_port: int,
        log_gphoto2: bool,
        camera: str|None = None,
        port: str|None = None,
        is_mtp_device: bool|None = None,
        entire_video_required: bool|None = None,
        entire_photo_required: bool|None = None,
    ) -> None:
        """
        List of files for which thumbnails are to be generated.
        All files  are assumed to have the same scan id.
        :param scan_id: id of the scan
        :param rpd_files: files from which to extract thumbnails
        :param name: name of the device
        :param proximity_seconds: the time elapsed between consecutive
         shots that is used to prioritize the order of thumbnail
         generation
        :param cache_dirs: the location where the cache directories
         should be created
        :param need_photo_cache_dir: if True, must use cache dir
         to extract photo thumbnail
        :param need_video_cache_dir: if True, must use cache dir
         to extract video thumbnail
        :param frontend_port: port to use to send to load balancer's
         front end
        :param log_gphoto2: if True, log libgphoto2 logging messages
        :param camera: If the thumbnails are being downloaded from a
         camera, this is the name of the camera, else None
        :param port: If the thumbnails are being downloaded from a
         camera, this is the port of the camera, else None
        :param entire_video_required: if the entire video is required
         to extract the thumbnail
        :param entire_photo_required: if the entire photo is required
         to extract the thumbnail
        """

        self.rpd_files = rpd_files
        self.scan_id = scan_id
        self.name = name
        self.proximity_seconds = proximity_seconds
        self.cache_dirs = cache_dirs
        self.need_photo_cache_dir = need_photo_cache_dir
        self.need_video_cache_dir = need_video_cache_dir
        self.frontend_port = frontend_port
        if camera is not None:
            assert port is not None
            assert entire_video_required is not None
        self.camera = camera
        self.port = port
        self.is_mtp_device = is_mtp_device
        self.log_gphoto2 = log_gphoto2
        self.entire_video_required = entire_video_required
        self.entire_photo_required = entire_photo_required


class GenerateThumbnailsResults:
    def __init__(
        self,
        rpd_file: RPDFile | None = None,
        thumbnail_bytes: bytes|None = None,
        scan_id: int|None = None,
        cache_dirs: CacheDirs | None = None,
        camera_removed: bool|None = None,
    ) -> None:
        self.rpd_file = rpd_file
        # If thumbnail_bytes is None, there is no thumbnail
        self.thumbnail_bytes = thumbnail_bytes
        self.scan_id = scan_id
        self.cache_dirs = cache_dirs
        self.camera_removed = camera_removed


class ThumbnailExtractorArgument:
    def __init__(
        self,
        rpd_file: RPDFile,
        task: ExtractionTask,
        processing: set[ExtractionProcessing],
        full_file_name_to_work_on: str,
        secondary_full_file_name: str,
        exif_buffer: bytearray | None,
        thumbnail_bytes: bytes,
        use_thumbnail_cache: bool,
        file_to_work_on_is_temporary: bool,
        write_fdo_thumbnail: bool,
        send_thumb_to_main: bool,
        force_exiftool: bool,
    ) -> None:
        self.rpd_file = rpd_file
        self.task = task
        self.processing = processing
        self.full_file_name_to_work_on = full_file_name_to_work_on
        self.secondary_full_file_name = secondary_full_file_name
        self.file_to_work_on_is_temporary = file_to_work_on_is_temporary
        self.exif_buffer = exif_buffer
        self.thumbnail_bytes = thumbnail_bytes
        self.use_thumbnail_cache = use_thumbnail_cache
        self.write_fdo_thumbnail = write_fdo_thumbnail
        self.send_thumb_to_main = send_thumb_to_main
        self.force_exiftool = force_exiftool


class RenameMoveFileManager(PushPullDaemonManager):
    """
    Manages the single instance daemon process that renames and moves
    files that have just been downloaded
    """

    message = pyqtSignal(bool, RPDFile, int)
    sequencesUpdate = pyqtSignal(int, list)
    renameProblems = pyqtSignal("PyQt_PyObject")

    def __init__(self, logging_port: int) -> None:
        super().__init__(logging_port=logging_port, thread_name=ThreadNames.rename)
        self._process_name = "Rename and Move File Manager"
        self._process_to_run = "renameandmovefile.py"

    def process_sink_data(self):
        data = pickle.loads(self.content)  # type: RenameAndMoveFileResults
        if data.move_succeeded is not None:

            self.message.emit(data.move_succeeded, data.rpd_file, data.download_count)

        elif data.problems is not None:
            self.renameProblems.emit(data.problems)
        else:
            assert data.stored_sequence_no is not None
            assert data.downloads_today is not None
            assert isinstance(data.downloads_today, list)
            self.sequencesUpdate.emit(data.stored_sequence_no, data.downloads_today)


class ThumbnailDaemonManager(PushPullDaemonManager):
    """
    Manages the process that extracts thumbnails after the file
    has already been downloaded and that writes FreeDesktop.org
    thumbnails. Not to be confused with ThumbnailManagerPara, which
    manages thumbnailing using processes that run in parallel,
    one for each device.
    """

    message = pyqtSignal(RPDFile, QPixmap)

    def __init__(self, logging_port: int) -> None:
        super().__init__(
            logging_port=logging_port, thread_name=ThreadNames.thumbnail_daemon
        )
        self._process_name = "Thumbnail Daemon Manager"
        self._process_to_run = "thumbnaildaemon.py"

    def process_sink_data(self) -> None:
        data = pickle.loads(self.content)  # type: GenerateThumbnailsResults
        if data.thumbnail_bytes is None:
            thumbnail = QPixmap()
        else:
            thumbnail = QImage.fromData(data.thumbnail_bytes)
            if thumbnail.isNull():
                thumbnail = QPixmap()
            else:
                thumbnail = QPixmap.fromImage(thumbnail)
        self.message.emit(data.rpd_file, thumbnail)


class OffloadManager(PushPullDaemonManager):
    """
    Handles tasks best run in a separate process
    """

    message = pyqtSignal(TemporalProximityGroups)
    downloadFolders = pyqtSignal(FoldersPreview)

    def __init__(self, logging_port: int) -> None:
        super().__init__(logging_port=logging_port, thread_name=ThreadNames.offload)
        self._process_name = "Offload Manager"
        self._process_to_run = "offload.py"

    def process_sink_data(self) -> None:
        data = pickle.loads(self.content)  # type: OffloadResults
        if data.proximity_groups is not None:
            self.message.emit(data.proximity_groups)
        elif data.folders_preview is not None:
            self.downloadFolders.emit(data.folders_preview)


class ScanManager(PublishPullPipelineManager):
    """
    Handles the processes that scan devices (cameras, external devices,
    this computer path)
    """

    scannedFiles = pyqtSignal(
        "PyQt_PyObject", "PyQt_PyObject", FileTypeCounter, "PyQt_PyObject", bool, bool
    )
    deviceError = pyqtSignal(int, CameraErrorCode, str)
    deviceDetails = pyqtSignal(int, "PyQt_PyObject", "PyQt_PyObject", str, str, bool)
    scanProblems = pyqtSignal(int, "PyQt_PyObject")
    fatalError = pyqtSignal(int)
    cameraRemovedDuringScan = pyqtSignal(int)

    def __init__(self, logging_port: int) -> None:
        super().__init__(logging_port=logging_port, thread_name=ThreadNames.scan)
        self._process_name = "Scan Manager"
        self._process_to_run = "scan.py"

    def process_sink_data(self) -> None:
        data = pickle.loads(self.content)  # type: ScanResults
        if data.rpd_files is not None:
            assert data.file_type_counter
            assert data.file_size_sum
            assert data.entire_video_required is not None
            assert data.entire_photo_required is not None
            self.scannedFiles.emit(
                data.rpd_files,
                (data.sample_photo, data.sample_video),
                data.file_type_counter,
                data.file_size_sum,
                data.entire_video_required,
                data.entire_photo_required,
            )
        else:
            assert data.scan_id is not None
            if data.error_code is not None:
                assert data.error_message is not None
                self.deviceError.emit(data.scan_id, data.error_code, data.error_message)
            elif data.optimal_display_name is not None or data.mount_point is not None:
                # Some values might have value None, so give them default value in case
                self.deviceDetails.emit(
                    data.scan_id,
                    data.storage_space or [],
                    data.storage_descriptions or [],
                    data.optimal_display_name,
                    data.mount_point or "",
                    data.is_apple_mobile or False,
                )
            elif data.problems is not None:
                self.scanProblems.emit(data.scan_id, data.problems)
            elif data.camera_removed is not None:
                self.cameraRemovedDuringScan.emit(data.scan_id)
            else:
                assert data.fatal_error
                self.fatalError.emit(data.scan_id)


class BackupManager(PublishPullPipelineManager):
    """
    Each backup "device" (it could be an external drive, or a user-
    specified path on the local file system) has associated with it one
    worker process. For example if photos and videos are both being
    backed up to the same external hard drive, one worker process
    handles both the photos and the videos. However if photos are being
    backed up to one drive, and videos to another, there would be a
    worker process for each drive (2 in total).
    """

    message = pyqtSignal(int, bool, bool, RPDFile, str, "PyQt_PyObject")
    bytesBackedUp = pyqtSignal("PyQt_PyObject", "PyQt_PyObject")
    backupProblems = pyqtSignal(int, "PyQt_PyObject")

    def __init__(self, logging_port: int) -> None:
        super().__init__(logging_port=logging_port, thread_name=ThreadNames.backup)
        self._process_name = "Backup Manager"
        self._process_to_run = "backupfile.py"

    def process_sink_data(self) -> None:
        data = pickle.loads(self.content)  # type: BackupResults
        if data.total_downloaded is not None:
            assert data.scan_id is not None
            assert data.chunk_downloaded >= 0
            assert data.total_downloaded >= 0
            self.bytesBackedUp.emit(data.scan_id, data.chunk_downloaded)
        elif data.backup_succeeded is not None:
            assert data.do_backup is not None
            assert data.rpd_file is not None
            self.message.emit(
                data.device_id,
                data.backup_succeeded,
                data.do_backup,
                data.rpd_file,
                data.backup_full_file_name,
                data.mdata_exceptions,
            )
        else:
            assert data.problems is not None
            self.backupProblems.emit(data.device_id, data.problems)


class CopyFilesManager(PublishPullPipelineManager):
    """
    Manage the processes that copy files from devices to the computer
    during the download process
    """

    message = pyqtSignal(bool, RPDFile, int, "PyQt_PyObject")
    tempDirs = pyqtSignal(int, str, str)
    bytesDownloaded = pyqtSignal(int, "PyQt_PyObject", "PyQt_PyObject")
    copyProblems = pyqtSignal(int, "PyQt_PyObject")
    cameraRemoved = pyqtSignal(int)

    def __init__(self, logging_port: int) -> None:
        super().__init__(logging_port=logging_port, thread_name=ThreadNames.copy)
        self._process_name = "Copy Files Manager"
        self._process_to_run = "copyfiles.py"

    def process_sink_data(self) -> None:
        data = pickle.loads(self.content)  # type: CopyFilesResults
        if data.total_downloaded is not None:
            assert data.scan_id is not None
            if data.chunk_downloaded < 0:
                logging.critical(
                    "Chunk downloaded is less than zero: %s", data.chunk_downloaded
                )
            if data.total_downloaded < 0:
                logging.critical(
                    "Chunk downloaded is less than zero: %s", data.total_downloaded
                )

            self.bytesDownloaded.emit(
                data.scan_id, data.total_downloaded, data.chunk_downloaded
            )

        elif data.copy_succeeded is not None:
            assert data.rpd_file is not None
            assert data.download_count is not None
            self.message.emit(
                data.copy_succeeded,
                data.rpd_file,
                data.download_count,
                data.mdata_exceptions,
            )

        elif data.problems is not None:
            self.copyProblems.emit(data.scan_id, data.problems)
        elif data.camera_removed is not None:
            self.cameraRemoved.emit(data.scan_id)

        else:
            assert data.photo_temp_dir is not None and data.video_temp_dir is not None
            assert data.scan_id is not None
            self.tempDirs.emit(data.scan_id, data.photo_temp_dir, data.video_temp_dir)
