import hashlib
import stat
from typing import Tuple, Optional, List

from clarabot_nano.common.handler.base import NanoHandlerError, MaybeConflictNanoHandlerError
from clarabot_nano.common.protocol.errors import (
    NANO_REQUEST_ERROR_HANDLER_BAD_REQUEST,
    NANO_REQUEST_ERROR_HANDLER_DENIED,
    NANO_REQUEST_ERROR_HANDLER_RESET_REQUEST,
    NANO_REQUEST_ERROR_HANDLER_TARGET_EXISTS,
    NANO_REQUEST_ERROR_SHARE_LOCKED_OPERATION,
)
from clarabot_nano.common.protocol.request import NanoRequestContext
from clarabot_nano.common.protocol.response import NanoBaseResponse
from clarabot_nano.common.request.server_fstx import NanoFilesystemPutRequest
from clarabot_nano.server.base.config import ServerConfig
from clarabot_nano.server.fstx.filesystem.operation.replace import ReplaceOperation
from clarabot_nano.server.fstx.filesystem.operation.write import (
    WriteTruncateOperation,
    SeekWriteOperation,
    AbstractWriteOperation,
)
from clarabot_nano.server.fstx.handler.base import (
    AbstractFilesystemTransactorNanoHandler,
    FilesystemTransactorServerHandleHelper,
)
from clarabot_nano.utility.file import temporary_file_by_target
from clarabot_nano.utility.path.error import attempt_share_locked_operation, SharedLockError
from clarabot_nano.utility.path.validate import ValidationError
from clarabot_nano.utility.serialize import msgpack_dumps, msgpack_loads, bigger_int_from_js_msgpack


class NanoFilesystemPutResponse(NanoBaseResponse):
    # only added to initialization response
    NORMALIZED_NAME = "norm_name"  # may be different from the input name due to normalization


class PutSystemHandler(AbstractFilesystemTransactorNanoHandler):
    """
    The optimal upload of a file is not straight forward to implement due to limitations of the clients and security
    requirements.
    The individual requests and data parts are safely encrypted and authenticated. Their integrity during transmission
    is thus guaranteed. The only point at which the file can get corrupted is when the client is reading from its
    storage or when it is written to the storage of the server. The server implementation should if possible check
    the file integrity without fully reading it back from the storage after the upload is finished. For this we assume
    all parts will be successfully written to disk or raise an error to tell the client the issue.
    The only additional integrity the server needs to check to ensure adequate safety is to ensure the order and
    number of parts the file consists of according to the client.
    The upload process will detect integrity errors if the source file's size of modification time changes, so the
    source file is somewhat checked to be consistent.
    """

    HANDLER_KEY = NanoFilesystemPutRequest.TYPE_KEY

    def __init__(
        self,
        helper: FilesystemTransactorServerHandleHelper,
        extracted_config: ServerConfig,
        context: dict,
        payload: dict,
    ):
        AbstractFilesystemTransactorNanoHandler.__init__(self, helper, extracted_config, context, payload)
        self.path = None  # will be created with chroot in execute
        if not isinstance(self.payload.get(NanoFilesystemPutRequest.PATH), str):
            raise NanoHandlerError(NANO_REQUEST_ERROR_HANDLER_BAD_REQUEST, "Path is missing", self.context)
        self.size = bigger_int_from_js_msgpack(self.payload.get(NanoFilesystemPutRequest.SIZE))
        if not isinstance(self.size, int) or self.size < -1:  # allow -1 for indeterminate
            raise NanoHandlerError(NANO_REQUEST_ERROR_HANDLER_BAD_REQUEST, "Invalid size", self.context)
        self.mtime = bigger_int_from_js_msgpack(self.payload.get(NanoFilesystemPutRequest.MTIME))
        if not isinstance(self.mtime, int) or self.mtime < -1:  # allow -1 for indeterminate
            raise NanoHandlerError(NANO_REQUEST_ERROR_HANDLER_BAD_REQUEST, "Invalid mtime", self.context)
        self.offset = bigger_int_from_js_msgpack(self.payload.get(NanoFilesystemPutRequest.OFFSET))
        if not isinstance(self.offset, int) or self.offset < 0:
            raise NanoHandlerError(NANO_REQUEST_ERROR_HANDLER_BAD_REQUEST, "Invalid offset", self.context)
        self.data = self.payload.get(NanoFilesystemPutRequest.DATA)
        if not isinstance(self.data, bytes):
            raise NanoHandlerError(NANO_REQUEST_ERROR_HANDLER_BAD_REQUEST, "Invalid data", self.context)
        self.smac = self.payload.get(NanoFilesystemPutRequest.SMAC)
        if self.smac is not None and (not isinstance(self.smac, bytes) or len(self.smac) != 16):
            raise NanoHandlerError(NANO_REQUEST_ERROR_HANDLER_BAD_REQUEST, "Invalid data", self.context)
        self.overwrite = bigger_int_from_js_msgpack(self.payload.get(NanoFilesystemPutRequest.OVERWRITE))
        if self.overwrite is not None and not isinstance(self.overwrite, int):
            raise NanoHandlerError(NANO_REQUEST_ERROR_HANDLER_BAD_REQUEST, "Invalid overwrite", self.context)
        if not self.can_write_drive() and not self.can_upload_drive():
            raise NanoHandlerError(
                NANO_REQUEST_ERROR_HANDLER_DENIED,
                f"No permission to put {self.payload.get(NanoFilesystemPutRequest.PATH)}",
                self.context,
            )

    async def _create_request_key(self) -> Tuple[bool, Optional[list], int]:
        return False, None, 1

    async def _execute_initial(self) -> dict:
        d = await AbstractFilesystemTransactorNanoHandler._execute_initial(self)
        try:
            self.path = self.chroot.new_path(
                self.payload[NanoFilesystemPutRequest.PATH],
                name_preprocess_fn=self.extracted_config.normalize_remote_path,
            )
            final_path = self.path.as_posix()
            # Tracking key is created to easily match up to a previously interrupted upload. This makes it near
            # automatic to continue or if needed restart an upload. The only bad scenario is that if the same
            # account wants to upload two different files to the same directory with the same name at the same
            # time. They will infinitely reset each other until one of them gives up and lets the other finish.
            tracking_key = (self.context[NanoRequestContext.ACCOUNT_ID], final_path)
            tmp_row = await self.helper.temp_file_db.get_tmp_file(*tracking_key)
            if not tmp_row or self.offset == 0:  # starting or restarting
                if self.offset != 0:
                    raise NanoHandlerError(
                        NANO_REQUEST_ERROR_HANDLER_RESET_REQUEST,
                        f"No active tracking for upload to {self.path}",
                        self.context,
                    )
                elif not tmp_row:
                    async with self.helper.fstx_queue.wait_ready(paths=(final_path,)):
                        tmp_name = temporary_file_by_target(self.path)
                else:
                    _, tmp_name, _ = tmp_row
                tmp_id = await self.helper.temp_file_db.create_tmp_file(
                    *tracking_key,
                    tmp_name,
                    msgpack_dumps([self.size, self.mtime]),
                )
                d[NanoFilesystemPutResponse.NORMALIZED_NAME] = self.path.name
            else:
                tmp_id, tmp_name, tmp_meta = tmp_row
                if msgpack_loads(tmp_meta) != [self.size, self.mtime]:
                    raise NanoHandlerError(
                        NANO_REQUEST_ERROR_HANDLER_RESET_REQUEST,
                        f"Active tracking mismatches metadata for upload to {self.path}",
                        self.context,
                    )
            temp_path = (self.path.parent / tmp_name).as_posix()
            # Lock on the work paths.
            async with self.helper.fstx_queue.wait_ready(
                paths=AbstractWriteOperation(0, temp_path, final_path, b"").paths()
            ) as submitter:
                # Final check before execution. This can fail if contesting operations have changed the filesystem.
                if self.path != self.chroot.new_path(
                    self.payload[NanoFilesystemPutRequest.PATH],
                    name_preprocess_fn=self.extracted_config.normalize_remote_path,
                ):
                    raise ValidationError("Depending paths are contested")
                self._validate_target_path()
                # Insert an incorrect mac-part for the current part before writing. This will prevent parallel
                # writes of the same file to seem complete, while one of the writes have failed and may have
                # broken a previous part. In other words: we invalidate the mac-store before each write and fix
                # it after the write is completed successfully by replacing the incorrect entry.
                await self.helper.temp_file_db.add_part_mac(tmp_id, self.offset, -1, b"")
                if not tmp_row or self.offset == 0:  # starting or restarting
                    await submitter(
                        WriteTruncateOperation(
                            self.context[NanoRequestContext.ACCOUNT_ID], temp_path, final_path, self.data
                        )
                    )  # TODO: timeout
                    await self.helper.temp_file_db.add_part_mac(
                        tmp_id,
                        self.offset,
                        len(self.data),
                        hashlib.sha256(self.data).digest()[:16],
                    )
                else:
                    await submitter(
                        SeekWriteOperation(
                            self.context[NanoRequestContext.ACCOUNT_ID],
                            temp_path,
                            final_path,
                            self.data,
                            self.offset,
                        )
                    )  # TODO: timeout
                    await self.helper.temp_file_db.add_part_mac(
                        tmp_id,
                        self.offset,
                        len(self.data),
                        hashlib.sha256(self.data).digest()[:16],
                    )
            if self.smac:
                # finalize upload by checking part consistency and MAC
                parts: List[int, int, bytes] = await self.helper.temp_file_db.get_all_part_mac(tmp_id)
                parts.sort(key=lambda part: part[0])
                smac = hashlib.sha256()
                prev_offset = 0
                for part_offset, part_size, part_mac in parts:
                    if prev_offset != part_offset:
                        raise NanoHandlerError(
                            NANO_REQUEST_ERROR_HANDLER_RESET_REQUEST,
                            f"Data part gap for upload to {self.path}",
                            self.context,
                        )
                    prev_offset += part_size
                    smac.update(part_mac)
                if smac.digest()[:16] != self.smac:
                    raise NanoHandlerError(
                        NANO_REQUEST_ERROR_HANDLER_RESET_REQUEST,
                        f"Data part MACs failed validation for upload to {self.path}",
                        self.context,
                    )

                async def _attempt_replace_target():
                    # move tmp to final path (TOCTOU ofc)
                    async with self.helper.fstx_queue.wait_ready(
                        ReplaceOperation(self.context[NanoRequestContext.ACCOUNT_ID], temp_path, final_path)
                    ) as submitter:
                        # Final check before execution. This can fail if contesting operations have changed
                        # the filesystem.
                        if self.path != self.chroot.new_path(
                            self.payload[NanoFilesystemPutRequest.PATH],
                            name_preprocess_fn=self.extracted_config.normalize_remote_path,
                        ):
                            raise ValidationError("Depending paths are contested")
                        self._validate_target_path()
                        await submitter()  # TODO: timeout
                        self._set_remote_file_security(final_path)

                try:
                    await attempt_share_locked_operation(3, 3.0, _attempt_replace_target)
                except SharedLockError as e:
                    raise NanoHandlerError(
                        NANO_REQUEST_ERROR_SHARE_LOCKED_OPERATION, "Move to final path failed", self.context
                    ) from e
                await self.helper.temp_file_db.drop_tmp_file(tmp_id)
        except (OSError, ValidationError) as e:
            raise MaybeConflictNanoHandlerError(
                NANO_REQUEST_ERROR_HANDLER_BAD_REQUEST, "File part write failed", self.context
            ) from e
        return d

    def _validate_target_path(self):
        # validate target path and permission to writing it
        try:
            s = self.path.lstat()
            if not stat.S_ISREG(s.st_mode):
                raise NanoHandlerError(
                    NANO_REQUEST_ERROR_HANDLER_TARGET_EXISTS,
                    "Target exists and cannot be overwritten",
                    self.context,
                )
        except OSError:  # the target path does not exist or inaccessible (can't discern)
            if not self.can_write_drive() and not self.can_upload_drive():  # CREATE
                raise NanoHandlerError(
                    NANO_REQUEST_ERROR_HANDLER_DENIED,
                    f"No permission to create file at {self.payload.get(NanoFilesystemPutRequest.PATH)}",
                    self.context,
                )
        else:  # overwriting target path
            if not self.can_write_drive():  # MODIFY
                raise NanoHandlerError(
                    NANO_REQUEST_ERROR_HANDLER_DENIED,
                    f"No permission to overwrite file at {self.payload.get(NanoFilesystemPutRequest.PATH)}",
                    self.context,
                )
            # TODO: require timestamp of target file for more explicit protection?
            if not self.overwrite:
                raise NanoHandlerError(
                    NANO_REQUEST_ERROR_HANDLER_TARGET_EXISTS,
                    "Target exists and no overwrite is requested",
                    self.context,
                )

    async def _execute_part(self, part: int, s_hmac_start_part: Optional[int]) -> dict:
        raise NanoHandlerError(NANO_REQUEST_ERROR_HANDLER_BAD_REQUEST, "Part request is invalid", self.context)

    def _log_message(self) -> str:
        if self.smac:
            return f"File wrote {self.path}"
        return f"File part wrote {self.path} {self.offset}-{self.offset+len(self.data)}"
