import logging
from io import StringIO
from typing import Tuple, Optional, Dict, List

import safe_radix32
from clarabot_crypto.keyring.adhoc import AdhocKeyring
from clarabot_crypto.keyring.base import MissingKey, InvalidKey
from clarabot_nano_common.common.error import maybe_rad32, log_escape_str
from clarabot_nano_common.common.handler.error import NanoHandlerError
from clarabot_nano_common.common.protocol.errors import (
    NANO_REQUEST_ERROR_HANDLER_BAD_REQUEST,
    NANO_REQUEST_ERROR_INTER_NANO_ERROR,
)
from clarabot_nano_common.common.protocol.request import NanoRequestContext
from clarabot_nano_common.common.protocol.response import NanoBaseResponse
from clarabot_nano_common.utility.algorithm import dict_nested_get
from clarabot_nano_common.utility.serialize import msgpack_dumps

from clarabot_nano.common.request.gateway import AddInterNanoJobRequest
from clarabot_nano.gateway.config import GatewayConfig
from clarabot_nano.gateway.handler.inter_nano_job.base import AbstractInterNanoJobHandler
from clarabot_nano.gateway.state.gateway import GatewayState
from clarabot_nano.gateway.state.inter import gateway_run_inter_nano_job
from clarabot_nano.inter.copy import InterNanoCopyJob
from clarabot_nano.inter.job import AbstractInterNanoJobQueue

logger = logging.getLogger(__name__)


class AddInterNanoJobResponse(NanoBaseResponse):
    JOB_ID = "job_id"


class AddInterNanoJobHandler(AbstractInterNanoJobHandler):
    EXECUTION_STATE_CAN_CONFLICT = False  # cacheable and request_key are constant, no conflict is possible
    SUCCESS_LOG_LEVEL = logging.DEBUG  # do not log this normally to avoid spam

    HANDLER_KEY = AddInterNanoJobRequest.TYPE_KEY

    JOB_TYPES = (InterNanoCopyJob.TYPE,)

    def __init__(self, helper: GatewayState, extracted_config: GatewayConfig, context: dict, payload: dict):
        AbstractInterNanoJobHandler.__init__(self, helper, extracted_config, context, payload)
        self.job_type = self.payload.get(AddInterNanoJobRequest.JOB_TYPE)
        if not isinstance(self.job_type, str) or self.job_type not in self.JOB_TYPES:
            raise NanoHandlerError(NANO_REQUEST_ERROR_HANDLER_BAD_REQUEST, "Invalid job-type", self)
        self.job_attrs = self.payload.get(AddInterNanoJobRequest.JOB_ATTRS)
        if not isinstance(self.job_attrs, dict) or not all(isinstance(k, str) for k in self.job_attrs.keys()):
            raise NanoHandlerError(NANO_REQUEST_ERROR_HANDLER_BAD_REQUEST, "Invalid job-attrs", self)
        # these will be saved for logging
        self.room_id = None
        self.job_id = None

    async def _create_request_key(self, secure: bool = False) -> Tuple[bool, Optional[list], int]:
        return self._secure_request_key(False, None, 1) if secure else False, None, 1

    async def _execute_initial(self) -> dict:
        self.room_id = dict_nested_get(self.context, NanoRequestContext.ROOM, NanoRequestContext.ROOM_ID)
        ijq: AbstractInterNanoJobQueue = self.helper.inter_job_queue
        # The "remote-room" selection is mandatory, but it may match the current one for specific requests to be done
        # in the current drive only, furthermore some requests may only operate in the current drive ever.
        remote_room_id, remote_room_id_int64 = self._parse_job_remote_room_id()
        # Additional credentials can be provided so this Nano can perform the copy. Users can pass an adhoc-token and
        # respective key to use for accessing remote drive that the current Nano's owner may not be authorized to. For
        # accessing publicly accessible drives the room_keys (from "share-url") can be provided. Basically:
        #   1. If the source room matches the target room:
        #       - room-keys are forbidden
        #       - adhoc-token is optional (should not be necessary in general, but may be used for convenience)
        #   2. If the source room is accessed by a public share:
        #       - a room-key must be provided
        #       - adhoc-token is forbidden
        #   3. If the source room is membership-accessed:
        #       - room-keys are forbidden
        #       - adhoc-token must be provided, unless the initiator is the owner of the target room
        room_keys = self._parse_job_room_keys()
        adhoc_token, adhoc_kr = self._parse_job_adhoc(remote_room_id)
        if self.room_id == remote_room_id_int64:
            # NOTE: Extra authorization is optional when targeting current room. The user is required to have higher
            # order permissions to what is needed for reading the remote (current) drive here. (can_write_drive)
            # NOTE: This will need to be re-examined when new adhoc types are introduced that do not imply drive-write
            # to be broader than what they require.
            if room_keys is not None:
                raise NanoHandlerError(NANO_REQUEST_ERROR_HANDLER_BAD_REQUEST, "Forbidden room_keys", self)
        else:
            # NOTE: Remote room access without adhoc-token *is* functional, but would be able to cross authorization
            # boundaries for some users among multiple Nanos when the initiating user is not the owner. For example:
            # If there is Nano A and B with their respective owners and A having permission to B, then U user having
            # write access to A, but no access to B can instruct Nano A to copy the contents of B to reveal them by
            # only knowing of the room B by its ID. To prevent this simple bypassing of the security policies the job
            # executing Nano is required to use a currently valid adhoc token to the other Nano, which by proxy proves
            # that the initiating user has access there as well by themselves.
            if room_keys is not None:  # means the target room is to be accessed by public share policy
                if adhoc_token is not None:
                    raise NanoHandlerError(NANO_REQUEST_ERROR_HANDLER_BAD_REQUEST, "Forbidden adhoc_token", self)
            elif adhoc_token is not None:
                if room_keys is not None:  # pragma: no cover; the first above branch covers this
                    raise NanoHandlerError(NANO_REQUEST_ERROR_HANDLER_BAD_REQUEST, "Forbidden room_keys", self)
            elif not self.is_request_by_owner():
                raise NanoHandlerError(NANO_REQUEST_ERROR_HANDLER_BAD_REQUEST, "Required adhoc_token", self)
        if self.job_type == InterNanoCopyJob.TYPE:
            drive_path = self._parse_job_drive_path()
            remote_path = self._parse_job_remote_path()
            remote_items = self._parse_job_remote_items()
            job = InterNanoCopyJob(
                safe_radix32.encode_fw(self.context[NanoRequestContext.ACCOUNT_ID]),
                safe_radix32.encode_fw(self.room_id),
                drive_path,
                remote_room_id,
                remote_path,
                remote_items,
                room_keys,
                adhoc_token,
                adhoc_kr,
            )
        else:  # pragma: no cover
            raise NanoHandlerError(NANO_REQUEST_ERROR_HANDLER_BAD_REQUEST, "Unhandled job-type", self)
        # The final step is to check if the provided credentials are accepted by the remote nano drive. (if applicable)
        try:
            if not await gateway_run_inter_nano_job(self.helper, job.create_check_job()):
                raise Exception(f"Inter-nano check-job failed for {job.TYPE}")
        except Exception:
            raise NanoHandlerError(
                NANO_REQUEST_ERROR_INTER_NANO_ERROR, "Failure during inter-nano functionality check", self
            )
        await ijq.add(job)
        d = await AbstractInterNanoJobHandler._execute_initial(self)
        d[AddInterNanoJobResponse.JOB_ID] = self.job_id = job.id
        return d

    def _parse_job_remote_room_id(self) -> Tuple[str, int]:
        remote_room_id = self.job_attrs.get("remote_room_id")
        if not isinstance(remote_room_id, str):
            raise NanoHandlerError(NANO_REQUEST_ERROR_HANDLER_BAD_REQUEST, "Invalid remote-room-id (1)", self)
        try:
            remote_room_id_int64 = safe_radix32.decode(remote_room_id)
            remote_room_id = safe_radix32.encode_fw(remote_room_id_int64)
        except Exception:
            raise NanoHandlerError(NANO_REQUEST_ERROR_HANDLER_BAD_REQUEST, "Invalid remote-room-id (2)", self)
        if remote_room_id != self.job_attrs.get("remote_room_id"):
            raise NanoHandlerError(NANO_REQUEST_ERROR_HANDLER_BAD_REQUEST, "Invalid remote-room-id (3)", self)
        return remote_room_id, remote_room_id_int64

    def _parse_job_drive_path(self) -> str:
        drive_path = self.job_attrs.get("drive_path")
        if not isinstance(drive_path, str):
            raise NanoHandlerError(NANO_REQUEST_ERROR_HANDLER_BAD_REQUEST, "Invalid drive-path", self)
        return drive_path

    def _parse_job_remote_path(self) -> str:
        remote_path = self.job_attrs.get("remote_path")
        if not isinstance(remote_path, str):
            raise NanoHandlerError(NANO_REQUEST_ERROR_HANDLER_BAD_REQUEST, "Invalid remote-path", self)
        return remote_path

    def _parse_job_room_keys(self) -> Optional[Dict[str, bytes]]:
        room_keys = self.job_attrs.get("room_keys")
        if room_keys is None:
            pass
        elif not isinstance(room_keys, dict) or not all(
            isinstance(k, str) and isinstance(v, bytes) for k, v in room_keys.items()
        ):
            raise NanoHandlerError(NANO_REQUEST_ERROR_HANDLER_BAD_REQUEST, "Invalid room-keys", self)
        return room_keys

    def _parse_job_adhoc(self, room_id: str) -> [Tuple[Optional[str], Optional[AdhocKeyring]]]:
        adhoc_token = self.job_attrs.get("adhoc_token")
        if adhoc_token is not None and not isinstance(adhoc_token, str):
            raise NanoHandlerError(NANO_REQUEST_ERROR_HANDLER_BAD_REQUEST, "Invalid adhoc-token", self)
        adhoc_keys = self.job_attrs.get("adhoc_keys")
        if adhoc_keys is None:
            pass
        elif not isinstance(adhoc_keys, dict) or not all(
            isinstance(k, str) and isinstance(v, bytes) for k, v in adhoc_keys.items()
        ):
            raise NanoHandlerError(NANO_REQUEST_ERROR_HANDLER_BAD_REQUEST, "Invalid adhoc-keys", self)
        if (adhoc_token is None) is not (adhoc_keys is None):
            raise NanoHandlerError(
                NANO_REQUEST_ERROR_HANDLER_BAD_REQUEST, "Both adhoc token and keys must be provided or neither", self
            )
        if adhoc_token:
            try:
                adhoc_kr = AdhocKeyring.load(room_id, adhoc_keys)
            except (MissingKey, InvalidKey):
                raise NanoHandlerError(NANO_REQUEST_ERROR_HANDLER_BAD_REQUEST, "Failed to load adhoc keyring", self)
        else:
            adhoc_kr = None
        return adhoc_token, adhoc_kr

    def _parse_job_remote_items(self) -> Optional[List[str]]:
        remote_items = self.job_attrs.get("remote_items")
        if remote_items is None:
            pass
        elif not isinstance(remote_items, list) or not all(isinstance(v, str) for v in remote_items):
            raise NanoHandlerError(NANO_REQUEST_ERROR_HANDLER_BAD_REQUEST, "Invalid remote-items (1)", self)
        # NOTE: values in remote_items are validated by remote/local processing with chroot utility.
        if len(msgpack_dumps(remote_items)) > self.context[NanoRequestContext.PART_SIZE] * 0.85:
            raise NanoHandlerError(NANO_REQUEST_ERROR_HANDLER_BAD_REQUEST, "Invalid remote-items (2)", self)
        return remote_items

    def _log_message(self) -> str:
        return f"Add inter-nano job {self.job_type} as id {self.job_id} for room {maybe_rad32(self.room_id)}"

    def build_error_message(self, buf: StringIO) -> None:
        buf.write("Add inter-nano job ")
        buf.write(log_escape_str(self.payload.get(AddInterNanoJobRequest.JOB_TYPE)))
        buf.write(" for room ")
        try:
            room_id = dict_nested_get(self.context, NanoRequestContext.ROOM, NanoRequestContext.ROOM_ID)
        except KeyError:
            pass
        else:
            buf.write(maybe_rad32(room_id))
