"""
Module for commands sent to the PiSCSI backend service.
"""

import logging
from pathlib import PurePath, Path
from functools import lru_cache

import piscsi_interface_pb2 as proto
from piscsi.return_codes import ReturnCodes
from piscsi.socket_cmds import SocketCmds

from piscsi.common_settings import (
    CFG_DIR,
    PROPERTIES_SUFFIX,
    ARCHIVE_FILE_SUFFIXES,
)

from util import unarchiver


class PiscsiCmds:
    """
    Class for commands sent to the PiSCSI backend service.
    """

    def __init__(self, sock_cmd: SocketCmds, token=None, locale="en"):
        self.sock_cmd = sock_cmd
        self.token = token
        self.locale = locale

    def send_pb_command(self, command):
        if logging.getLogger().isEnabledFor(logging.DEBUG):
            logging.debug(self.format_pb_command(command))

        return self.sock_cmd.send_pb_command(command.SerializeToString())

    def list_images(self):
        """
        Sends a IMAGE_FILES_INFO command to the server
        Returns a (dict) with (bool) status, (str) msg, and (list) of (dict)s files
        """
        from piscsi.file_cmds import FileCmds

        self.file_cmd = FileCmds(piscsi=self)

        command = proto.PbCommand()
        command.operation = proto.PbOperation.DEFAULT_IMAGE_FILES_INFO
        command.params["token"] = self.token
        command.params["locale"] = self.locale

        data = self.send_pb_command(command)
        result = proto.PbResult()
        result.ParseFromString(data)

        server_info = self.get_server_info()
        files = []
        for file in result.image_files_info.image_files:
            prop_file_path = Path(CFG_DIR) / f"{file.name}.{PROPERTIES_SUFFIX}"
            # Add properties meta data for the image, if matching prop file is found
            if prop_file_path.exists():
                process = self.file_cmd.read_drive_properties(prop_file_path)
                prop = process["conf"]
            else:
                prop = False

            archive_contents = []
            if PurePath(file.name).suffix.lower()[1:] in ARCHIVE_FILE_SUFFIXES:
                try:
                    archive_info = self._get_archive_info(
                        f"{server_info['image_dir']}/{file.name}",
                        _cache_extra_key=file.size,
                    )

                    properties_files = [
                        x["path"]
                        for x in archive_info["members"]
                        if x["path"].endswith(PROPERTIES_SUFFIX)
                    ]

                    for member in archive_info["members"]:
                        if member["is_dir"] or member["is_resource_fork"]:
                            continue

                        if PurePath(member["path"]).suffix.lower()[1:] == PROPERTIES_SUFFIX:
                            member["is_properties_file"] = True
                        elif f"{member['path']}.{PROPERTIES_SUFFIX}" in properties_files:
                            member["related_properties_file"] = (
                                f"{member['path']}.{PROPERTIES_SUFFIX}"
                            )

                        archive_contents.append(member)
                except (unarchiver.LsarCommandError, unarchiver.LsarOutputError):
                    pass

            size_mb = "{:,.1f}".format(file.size / 1024 / 1024)
            dtype = proto.PbDeviceType.Name(file.type)
            files.append(
                {
                    "name": file.name,
                    "size": file.size,
                    "size_mb": size_mb,
                    "detected_type": dtype,
                    "prop": prop,
                    "archive_contents": archive_contents,
                }
            )

        return {"status": result.status, "msg": result.msg, "files": files}

    def get_server_info(self):
        """
        Sends a SERVER_INFO command to the server.
        Returns a dict with:
        - (bool) status
        - (str) version (PiSCSI version number)
        - (list) of (str) log_levels (the log levels PiSCSI supports)
        - (str) current_log_level
        - (list) of (int) reserved_ids
        - (str) image_dir, path to the default images directory
        - (int) scan_depth, the current images directory scan depth
        - 5 distinct (list)s of (str)s with file endings recognized by PiSCSI
        """
        command = proto.PbCommand()
        command.operation = proto.PbOperation.SERVER_INFO
        command.params["token"] = self.token
        command.params["locale"] = self.locale

        data = self.send_pb_command(command)
        result = proto.PbResult()
        result.ParseFromString(data)
        version = (
            str(result.server_info.version_info.major_version)
            + "."
            + str(result.server_info.version_info.minor_version)
            + "."
            + str(result.server_info.version_info.patch_version)
        )
        log_levels = list(result.server_info.log_level_info.log_levels)
        current_log_level = result.server_info.log_level_info.current_log_level
        reserved_ids = list(result.server_info.reserved_ids_info.ids)
        image_dir = result.server_info.image_files_info.default_image_folder
        scan_depth = result.server_info.image_files_info.depth

        # Creates lists of file endings recognized by PiSCSI
        mappings = result.server_info.mapping_info.mapping
        schd = []
        scrm = []
        scmo = []
        sccd = []
        sctp = []
        for dtype in mappings:
            if mappings[dtype] == proto.PbDeviceType.SCHD:
                schd.append(dtype)
            elif mappings[dtype] == proto.PbDeviceType.SCRM:
                scrm.append(dtype)
            elif mappings[dtype] == proto.PbDeviceType.SCMO:
                scmo.append(dtype)
            elif mappings[dtype] == proto.PbDeviceType.SCCD:
                sccd.append(dtype)
            elif mappings[dtype] == proto.PbDeviceType.SCTP:
                sctp.append(dtype)

        return {
            "status": result.status,
            "version": version,
            "log_levels": log_levels,
            "current_log_level": current_log_level,
            "reserved_ids": reserved_ids,
            "image_dir": image_dir,
            "scan_depth": scan_depth,
            "schd": schd,
            "scrm": scrm,
            "scmo": scmo,
            "sccd": sccd,
            "sctp": sctp,
        }

    def get_reserved_ids(self):
        """
        Sends a RESERVED_IDS_INFO command to the server.
        Returns a dict with:
        - (bool) status
        - (list) of (int) ids -- currently reserved SCSI IDs
        """
        command = proto.PbCommand()
        command.operation = proto.PbOperation.RESERVED_IDS_INFO
        command.params["token"] = self.token
        command.params["locale"] = self.locale

        data = self.send_pb_command(command)
        result = proto.PbResult()
        result.ParseFromString(data)
        scsi_ids = []
        for scsi_id in result.reserved_ids_info.ids:
            scsi_ids.append(str(scsi_id))

        return {"status": result.status, "ids": scsi_ids}

    def get_network_info(self):
        """
        Sends a NETWORK_INTERFACES_INFO command to the server.
        Returns a dict with:
        - (bool) status
        - (list) of (str) ifs (network interfaces detected by PiSCSI)
        """
        command = proto.PbCommand()
        command.operation = proto.PbOperation.NETWORK_INTERFACES_INFO
        command.params["token"] = self.token
        command.params["locale"] = self.locale

        data = self.send_pb_command(command)
        result = proto.PbResult()
        result.ParseFromString(data)
        ifs = result.network_interfaces_info.name
        return {"status": result.status, "ifs": list(ifs)}

    def get_device_types(self):
        """
        Sends a DEVICE_TYPES_INFO command to the server.
        Returns a dict with:
        - (bool) status
        - (dict) device_types, where keys are the four letter device type acronym,
          and the value is a (dict) of supported parameters and their default values.
        """
        command = proto.PbCommand()
        command.operation = proto.PbOperation.DEVICE_TYPES_INFO
        command.params["token"] = self.token
        command.params["locale"] = self.locale

        data = self.send_pb_command(command)
        result = proto.PbResult()
        result.ParseFromString(data)
        device_types = {}
        for device in result.device_types_info.properties:
            params = {}
            for key, value in device.properties.default_params.items():
                params[key] = value
            device_types[proto.PbDeviceType.Name(device.type)] = {
                "removable": device.properties.removable,
                "supports_file": device.properties.supports_file,
                "params": params,
                "block_sizes": list(device.properties.block_sizes),
            }
        return {"status": result.status, "device_types": device_types}

    def get_removable_device_types(self):
        """
        Returns a (list) of (str) of four letter device acronyms
        that are of the removable type.
        """
        device_types = self.get_device_types()
        removable_device_types = []
        for device, value in device_types["device_types"].items():
            if value["removable"]:
                removable_device_types.append(device)
        return removable_device_types

    def get_disk_device_types(self):
        """
        Returns a (list) of (str) of four letter device acronyms
        that take image files as arguments.
        """
        device_types = self.get_device_types()
        disk_device_types = []
        for device, value in device_types["device_types"].items():
            if value["supports_file"]:
                disk_device_types.append(device)
        return disk_device_types

    def get_peripheral_device_types(self):
        """
        Returns a (list) of (str) of four letter device acronyms
        that don't take image files as arguments.
        """
        device_types = self.get_device_types()
        image_device_types = self.get_disk_device_types()
        peripheral_device_types = [
            x for x in device_types["device_types"] if x not in image_device_types
        ]
        return peripheral_device_types

    def get_image_files_info(self):
        """
        Sends a DEFAULT_IMAGE_FILES_INFO command to the server.
        Returns a dict with:
        - (bool) status
        - (str) images_dir, path to images dir
        - (list) of (str) image_files
        - (int) scan_depth, the current scan depth
        """
        command = proto.PbCommand()
        command.operation = proto.PbOperation.DEFAULT_IMAGE_FILES_INFO
        command.params["token"] = self.token
        command.params["locale"] = self.token

        data = self.send_pb_command(command)
        result = proto.PbResult()
        result.ParseFromString(data)
        images_dir = result.image_files_info.default_image_folder
        image_files = result.image_files_info.image_files
        scan_depth = result.image_files_info.depth
        return {
            "status": result.status,
            "images_dir": images_dir,
            "image_files": image_files,
            "scan_depth": scan_depth,
        }

    def attach_device(self, scsi_id, **kwargs):
        """
        Takes (int) scsi_id and kwargs containing 0 or more device properties

        If the current attached device is a removable device wihout media inserted,
        this sends a INJECT command to the server.
        If there is no currently attached device, this sends the ATTACH command to the server.

        Returns (bool) status and (str) msg

        """
        command = proto.PbCommand()
        command.params["token"] = self.token
        command.params["locale"] = self.locale
        devices = proto.PbDeviceDefinition()
        devices.id = int(scsi_id)

        if kwargs.get("device_type"):
            devices.type = proto.PbDeviceType.Value(str(kwargs["device_type"]))
        if kwargs.get("unit"):
            devices.unit = kwargs["unit"]
        if kwargs.get("params") and isinstance(kwargs["params"], dict):
            for param in kwargs["params"]:
                devices.params[param] = kwargs["params"][param]

        # Handling the inserting of media into an attached removable type device
        device_type = kwargs.get("device_type", None)
        currently_attached = self.list_devices(scsi_id, kwargs.get("unit"))["device_list"]
        if currently_attached:
            current_type = currently_attached[0]["device_type"]
        else:
            current_type = None

        removable_device_types = self.get_removable_device_types()
        if device_type in removable_device_types and current_type in removable_device_types:
            if current_type != device_type:
                parameters = {
                    "device_type": device_type,
                    "current_device_type": current_type,
                }
                return {
                    "status": False,
                    "return_code": ReturnCodes.ATTACHIMAGE_COULD_NOT_ATTACH,
                    "parameters": parameters,
                }
            command.operation = proto.PbOperation.INSERT

        # Handling attaching a new device
        else:
            command.operation = proto.PbOperation.ATTACH
            if kwargs.get("vendor"):
                devices.vendor = kwargs["vendor"]
            if kwargs.get("product"):
                devices.product = kwargs["product"]
            if kwargs.get("revision"):
                devices.revision = kwargs["revision"]
            if kwargs.get("block_size"):
                devices.block_size = int(kwargs["block_size"])

        command.devices.append(devices)

        data = self.send_pb_command(command)
        result = proto.PbResult()
        result.ParseFromString(data)
        return {"status": result.status, "msg": result.msg}

    def detach_by_id(self, scsi_id, unit=None):
        """
        Takes (int) scsi_id and optional (int) unit.
        Sends a DETACH command to the server.
        Returns (bool) status and (str) msg.
        """
        devices = proto.PbDeviceDefinition()
        devices.id = int(scsi_id)
        if unit is not None:
            devices.unit = int(unit)

        command = proto.PbCommand()
        command.operation = proto.PbOperation.DETACH
        command.devices.append(devices)
        command.params["token"] = self.token
        command.params["locale"] = self.locale

        data = self.send_pb_command(command)
        result = proto.PbResult()
        result.ParseFromString(data)
        return {"status": result.status, "msg": result.msg}

    def detach_all(self):
        """
        Sends a DETACH_ALL command to the server.
        Returns (bool) status and (str) msg.
        """
        command = proto.PbCommand()
        command.operation = proto.PbOperation.DETACH_ALL
        command.params["token"] = self.token
        command.params["locale"] = self.locale

        data = self.send_pb_command(command)
        result = proto.PbResult()
        result.ParseFromString(data)
        return {"status": result.status, "msg": result.msg}

    def eject_by_id(self, scsi_id, unit=None):
        """
        Takes (int) scsi_id and optional (int) unit.
        Sends an EJECT command to the server.
        Returns (bool) status and (str) msg.
        """
        devices = proto.PbDeviceDefinition()
        devices.id = int(scsi_id)
        if unit is not None:
            devices.unit = int(unit)

        command = proto.PbCommand()
        command.operation = proto.PbOperation.EJECT
        command.devices.append(devices)
        command.params["token"] = self.token
        command.params["locale"] = self.locale

        data = self.send_pb_command(command)
        result = proto.PbResult()
        result.ParseFromString(data)
        return {"status": result.status, "msg": result.msg}

    def list_devices(self, scsi_id=None, unit=None):
        """
        Takes optional (int) scsi_id and optional (int) unit.
        Sends a DEVICES_INFO command to the server.
        If no scsi_id is provided, returns a (list) of (dict)s of all attached devices.
        If scsi_id is is provided, returns a (list) of one (dict) for the given device.
        If no attached device is found, returns an empty (list).
        Returns (bool) status, (list) of dicts device_list
        """
        command = proto.PbCommand()
        command.operation = proto.PbOperation.DEVICES_INFO
        command.params["token"] = self.token
        command.params["locale"] = self.locale

        # If method is called with scsi_id parameter, return the info on those devices
        # Otherwise, return the info on all attached devices
        if scsi_id is not None:
            device = proto.PbDeviceDefinition()
            device.id = int(scsi_id)
            if unit is not None:
                device.unit = int(unit)
            command.devices.append(device)

        data = self.send_pb_command(command)
        result = proto.PbResult()
        result.ParseFromString(data)

        device_list = []

        # Return an empty (list) if no devices are attached
        if not result.devices_info.devices:
            return {"status": False, "device_list": []}

        image_files_info = self.get_image_files_info()
        i = 0
        while i < len(result.devices_info.devices):
            did = result.devices_info.devices[i].id
            dunit = result.devices_info.devices[i].unit
            dtype = proto.PbDeviceType.Name(result.devices_info.devices[i].type)
            dstat = result.devices_info.devices[i].status
            dprop = result.devices_info.devices[i].properties

            # Building the status string
            dstat_msg = []
            if dprop.read_only:
                dstat_msg.append("Read-Only")
            if dstat.protected and dprop.protectable:
                dstat_msg.append("Write-Protected")
            if dstat.removed and dprop.removable:
                dstat_msg.append("No Media")
            if dstat.locked and dprop.lockable:
                dstat_msg.append("Locked")

            dpath = result.devices_info.devices[i].file.name
            dfile = dpath.replace(image_files_info["images_dir"] + "/", "")
            dparam = dict(result.devices_info.devices[i].params)
            dven = result.devices_info.devices[i].vendor
            dprod = result.devices_info.devices[i].product
            drev = result.devices_info.devices[i].revision
            dblock = result.devices_info.devices[i].block_size
            dsize = int(result.devices_info.devices[i].block_count) * int(dblock)

            device_list.append(
                {
                    "id": did,
                    "unit": dunit,
                    "device_type": dtype,
                    "status": ", ".join(dstat_msg),
                    "image": dpath,
                    "file": dfile,
                    "params": dparam,
                    "vendor": dven,
                    "product": dprod,
                    "revision": drev,
                    "block_size": dblock,
                    "size": dsize,
                }
            )
            i += 1

        return {"status": result.status, "msg": result.msg, "device_list": device_list}

    def reserve_scsi_ids(self, reserved_scsi_ids):
        """
        Sends the RESERVE_IDS command to the server to reserve SCSI IDs.
        Takes a (list) of (str) as argument.
        """
        command = proto.PbCommand()
        command.operation = proto.PbOperation.RESERVE_IDS
        command.params["ids"] = ",".join(reserved_scsi_ids)
        command.params["token"] = self.token
        command.params["locale"] = self.locale

        data = self.send_pb_command(command)
        result = proto.PbResult()
        result.ParseFromString(data)
        return {"status": result.status, "msg": result.msg}

    def set_log_level(self, log_level):
        """
        Sends a LOG_LEVEL command to the server.
        Takes (str) log_level as an argument.
        Returns (bool) status and (str) msg.
        """
        command = proto.PbCommand()
        command.operation = proto.PbOperation.LOG_LEVEL
        command.params["level"] = str(log_level)
        command.params["token"] = self.token
        command.params["locale"] = self.locale

        data = self.send_pb_command(command)
        result = proto.PbResult()
        result.ParseFromString(data)
        return {"status": result.status, "msg": result.msg}

    def shutdown(self, mode):
        """
        Sends a SHUT_DOWN command to the server.
        Takes (str) mode as an argument.
        The backend will use system calls to reboot or shut down the system.
        It can also shut down the backend process itself.
        Returns (bool) status and (str) msg.
        """
        command = proto.PbCommand()
        command.operation = proto.PbOperation.SHUT_DOWN
        command.params["mode"] = str(mode)
        command.params["token"] = self.token
        command.params["locale"] = self.locale

        data = self.send_pb_command(command)
        result = proto.PbResult()
        result.ParseFromString(data)
        return {"status": result.status, "msg": result.msg}

    def is_token_auth(self):
        """
        Sends a CHECK_AUTHENTICATION command to the server.
        Tells you whether PiSCSI backend is protected by a token password or not.
        Returns (bool) status and (str) msg.
        """
        command = proto.PbCommand()
        command.operation = proto.PbOperation.CHECK_AUTHENTICATION

        data = self.send_pb_command(command)
        result = proto.PbResult()
        result.ParseFromString(data)
        return {"status": result.status, "msg": result.msg}

    def format_pb_command(self, command):
        """
        Formats the Protobuf command for output
        """
        message = f"Sending: {proto.PbOperation.Name(command.operation)}"

        params = {
            name: "***" if name == "token" else value
            for (name, value) in sorted(command.params.items())
        }
        message += f", params: {params}"

        for device in command.devices:
            formatted_device = {
                key: value
                for (key, value) in {
                    "id": device.id,
                    "unit": device.unit,
                    "type": proto.PbDeviceType.Name(device.type) if device.type else None,
                    "params": device.params,
                    "vendor": device.vendor,
                    "product": device.product,
                    "revision": device.revision,
                }.items()
                if key == "id" or value
            }
            message += f", device: {formatted_device}"

        return message

    # noinspection PyMethodMayBeStatic
    @lru_cache(maxsize=32)
    def _get_archive_info(self, file_path, **kwargs):
        """
        Cached wrapper method to improve performance, e.g. on index screen
        """
        try:
            return unarchiver.inspect_archive(file_path)
        except (unarchiver.LsarCommandError, unarchiver.LsarOutputError) as error:
            logging.error(str(error))
            raise
