# -*- coding: utf-8 -*-
from base64 import b64decode
from io import BytesIO
from pathlib import Path
from typing import Any, Callable, List, NoReturn, Optional, Type, Union

import six
import wrapt
from brewtils.config import get_connection_info
from brewtils.errors import (
    BrewtilsException,
    ConflictError,
    DeleteError,
    FetchError,
    NotFoundError,
    RestConnectionError,
    RestError,
    SaveError,
    TooLargeError,
    ValidationError,
    WaitExceededError,
    _deprecate,
)
from brewtils.models import BaseModel, Event, PatchOperation
from brewtils.rest.client import RestClient
from brewtils.schema_parser import SchemaParser
from requests import Response


def get_easy_client(**kwargs):
    # type: (**Any) -> EasyClient
    """Easy way to get an EasyClient

    The benefit to this method over creating an EasyClient directly is that
    this method will also search the environment for parameters. Kwargs passed
    to this method will take priority, however.

    Args:
        **kwargs: Options for configuring the EasyClient

    Returns:
        brewtils.rest.easy_client.EasyClient: The configured client
    """
    return EasyClient(**get_connection_info(**kwargs))


def handle_response_failure(response, default_exc=RestError, raise_404=True):
    # type: (Response, Type[BrewtilsException], bool) -> NoReturn
    """Deal with a response with non-2xx status code

    Args:
        response: The response object
        default_exc: The exception to raise if no specific exception is warranted
        raise_404: If True a response with status code 404 will raise a NotFoundError.
            If False the method will return None.

    Returns:
        None - this function will always raise

    Raises:
        NotFoundError: Status code 404 and raise_404 is True
        WaitExceededError: Status code 408
        ConflictError: Status code 409
        TooLargeError: Status code 413
        ValidationError: Any other 4xx status codes
        RestConnectionError: Status code 503
        default_exc: Any other status code
    """
    try:
        message = response.json()
    except ValueError:
        message = response.text

    if response.status_code == 404:
        if raise_404:
            raise NotFoundError(message)
        else:
            return None
    elif response.status_code == 408:
        raise WaitExceededError(message)
    elif response.status_code == 409:
        raise ConflictError(message)
    elif response.status_code == 413:
        raise TooLargeError(message)
    elif 400 <= response.status_code < 500:
        raise ValidationError(message)
    elif response.status_code == 503:
        raise RestConnectionError(message)
    else:
        raise default_exc(message)


def wrap_response(
    return_boolean=False,  # type: bool
    parse_method=None,  # type: Optional[str]
    parse_many=False,  # type: bool
    default_exc=RestError,  # type: Type[BrewtilsException]
    raise_404=True,  # type: bool
):
    # type: (...) -> Callable[..., Union[bool, Response, BaseModel, List[BaseModel]]]
    """Decorator to consolidate response parsing and error handling

    Args:
        return_boolean: If True, a successful response will also return True
        parse_method: Response json will be passed to this method of the SchemaParser
        parse_many: Will be passed as the 'many' parameter when parsing the response
        default_exc: Will be passed to handle_response_failure for failed responses
        raise_404: Will be passed to handle_response_failure for failed responses

    Returns:
        - True if return_boolean is True and the response status code is 2xx.
        - The response object if return_boolean is False and parse_method is ""
        - A parsed Brewtils model if return_boolean is False and parse_method is defined

    Raises:
        RestError: The response has a non-2xx status code. Note that the specific
            exception raised depends on the response status code and the argument passed
            as the default_exc parameter.

    """

    @wrapt.decorator
    def wrapper(wrapped, _instance, args, kwargs):
        response = wrapped(*args, **kwargs)

        if response.ok:
            if return_boolean:
                return True

            if parse_method is None:
                return response.json()

            return getattr(SchemaParser, parse_method)(response.json(), many=parse_many)
        else:
            handle_response_failure(
                response, default_exc=default_exc, raise_404=raise_404
            )

    return wrapper


class EasyClient(object):
    """Client for simplified communication with Beergarden

    This class is intended to be a middle ground between the RestClient and
    SystemClient. It provides a 'cleaner' interface to some common Beergarden
    operations than is exposed by the lower-level RestClient. On the other hand,
    the SystemClient is much better for generating Beergarden Requests.

    Args:
        bg_host (str): Beer-garden hostname
        bg_port (int): Beer-garden port
        bg_url_prefix (str): URL path that will be used as a prefix when communicating
            with Beer-garden. Useful if Beer-garden is running on a URL other than '/'.
        ssl_enabled (bool): Whether to use SSL for Beer-garden communication
        ca_cert (str): Path to certificate file containing the certificate of the
            authority that issued the Beer-garden server certificate
        ca_verify (bool): Whether to verify Beer-garden server certificate
        client_cert (str): Path to client certificate to use when communicating with
            Beer-garden
        api_version (int): Beer-garden API version to use
        client_timeout (int): Max time to wait for Beer-garden server response
        username (str): Username for Beer-garden authentication
        password (str): Password for Beer-garden authentication
        access_token (str): Access token for Beer-garden authentication
        refresh_token (str): Refresh token for Beer-garden authentication
    """

    _default_file_params = {
        "chunk_size": 255 * 1024,
    }

    def __init__(self, *args, **kwargs):
        # This points DeprecationWarnings at the right line
        kwargs.setdefault("stacklevel", 4)

        self.client = RestClient(*args, **kwargs)

    def can_connect(self, **kwargs):
        # type: (**Any) -> bool
        """Determine if the Beergarden server is responding.

        Args:
            **kwargs: Keyword arguments passed to the underlying Requests method

        Returns:
            A bool indicating if the connection attempt was successful. Will
            return False only if a ConnectionError is raised during the attempt.
            Any other exception will be re-raised.

        Raises:
            requests.exceptions.RequestException:
                The connection attempt resulted in an exception that indicates
                something other than a basic connection error. For example,
                an error with certificate verification.

        """
        return self.client.can_connect(**kwargs)

    @wrap_response(default_exc=FetchError)
    def get_version(self, **kwargs):
        """Get Bartender, Brew-view, and API version information

        Args:
            **kwargs: Extra parameters

        Returns:
            dict: Response object with version information in the body

        """
        return self.client.get_version(**kwargs)

    @wrap_response(default_exc=FetchError)
    def get_config(self):
        """Get configuration

        Returns:
            dict: Configuration dictionary

        """
        return self.client.get_config()

    @wrap_response(default_exc=FetchError)
    def get_logging_config(self, system_name=None, local=False):
        """Get a logging configuration

        Note that the system_name is not relevant and is only provided for
        backward-compatibility.

        Args:
            system_name (str): UNUSED

        Returns:
            dict: The configuration object

        """
        return self.client.get_logging_config(local=local)

    @wrap_response(
        parse_method="parse_system", parse_many=False, default_exc=FetchError
    )
    def get_system(self, system_id, **kwargs):
        """Get a System

        Args:
            system_id: The Id

        Returns:
            The System

        """
        return self.client.get_system(system_id, **kwargs)

    def find_unique_system(self, **kwargs):
        """Find a unique system

        .. note::
            If 'id' is a given keyword argument then all other parameters will
            be ignored.

        Args:
            **kwargs: Search parameters

        Returns:
            System, None: The System if found, None otherwise

        Raises:
            FetchError: More than one matching System was found

        """
        if "id" in kwargs:
            try:
                return self.get_system(kwargs.pop("id"), **kwargs)
            except NotFoundError:
                return None
        else:
            systems = self.find_systems(**kwargs)

            if not systems:
                return None

            if len(systems) > 1:
                raise FetchError("More than one matching System found")

            return systems[0]

    @wrap_response(parse_method="parse_system", parse_many=True, default_exc=FetchError)
    def find_systems(self, **kwargs):
        """Find Systems using keyword arguments as search parameters

        Args:
            **kwargs: Search parameters

        Returns:
            List[System]: List of Systems matching the search parameters

        """
        return self.client.get_systems(**kwargs)

    @wrap_response(parse_method="parse_system", parse_many=False, default_exc=SaveError)
    def create_system(self, system):
        """Create a new System

        Args:
            system (System): The System to create

        Returns:
            System: The newly-created system

        """
        return self.client.post_systems(SchemaParser.serialize_system(system))

    @wrap_response(parse_method="parse_system", parse_many=False, default_exc=SaveError)
    def update_system(self, system_id, new_commands=None, **kwargs):
        """Update a System

        Args:
            system_id (str): The System ID
            new_commands (Optional[List[Command]]): New System commands

        Keyword Args:
            add_instance (Instance): An Instance to append
            metadata (dict): New System metadata
            description (str): New System description
            display_name (str): New System display name
            icon_name (str): New System icon name
            template (str): New System template

        Returns:
            System: The updated system

        """
        operations = []

        if new_commands is not None:
            commands = SchemaParser.serialize_command(
                new_commands, to_string=False, many=True
            )
            operations.append(PatchOperation("replace", "/commands", commands))

        add_instance = kwargs.pop("add_instance", None)
        if add_instance:
            instance = SchemaParser.serialize_instance(add_instance, to_string=False)
            operations.append(PatchOperation("add", "/instance", instance))

        metadata = kwargs.pop("metadata", {})
        if metadata:
            operations.append(PatchOperation("update", "/metadata", metadata))

        # The remaining kwargs are all strings
        # Sending an empty string (instead of None) ensures they're actually cleared
        for key, value in kwargs.items():
            operations.append(PatchOperation("replace", "/%s" % key, value or ""))

        return self.client.patch_system(
            system_id, SchemaParser.serialize_patch(operations, many=True)
        )

    def remove_system(self, **kwargs):
        """Remove a unique System

        Args:
            **kwargs: Search parameters

        Returns:
            bool: True if removal was successful

        Raises:
            FetchError: Couldn't find a System matching given parameters

        """
        system = self.find_unique_system(**kwargs)

        if system is None:
            raise FetchError("No matching System found")

        return self._remove_system_by_id(system.id)

    @wrap_response(
        parse_method="parse_instance", parse_many=False, default_exc=SaveError
    )
    def initialize_instance(self, instance_id, runner_id=None):
        """Start an Instance

        Args:
            instance_id (str): The Instance ID
            runner_id (str): The PluginRunner ID, if any

        Returns:
            Instance: The updated Instance

        """
        return self.client.patch_instance(
            instance_id,
            SchemaParser.serialize_patch(
                PatchOperation(operation="initialize", value={"runner_id": runner_id})
            ),
        )

    @wrap_response(
        parse_method="parse_instance", parse_many=False, default_exc=FetchError
    )
    def get_instance(self, instance_id):
        """Get an Instance

        Args:
            instance_id: The Id

        Returns:
            The Instance

        """
        return self.client.get_instance(instance_id)

    @wrap_response(
        parse_method="parse_instance", parse_many=False, default_exc=SaveError
    )
    def update_instance(self, instance_id, **kwargs):
        """Update an Instance status

        Args:
            instance_id (str): The Instance ID

        Keyword Args:
            new_status (str): The new status
            metadata (dict): Will be added to existing instance metadata

        Returns:
            Instance: The updated Instance

        """
        operations = []
        new_status = kwargs.pop("new_status", None)
        metadata = kwargs.pop("metadata", {})

        if new_status:
            operations.append(PatchOperation("replace", "/status", new_status))

        if metadata:
            operations.append(PatchOperation("update", "/metadata", metadata))

        return self.client.patch_instance(
            instance_id, SchemaParser.serialize_patch(operations, many=True)
        )

    def get_instance_status(self, instance_id):
        """
        .. deprecated: 3.0
            Will be removed in 4.0. Use ``get_instance()`` instead

        Get an Instance's status

        Args:
            instance_id: The Id

        Returns:
            The Instance's status

        """
        _deprecate(
            "This method is deprecated and scheduled to be removed in 4.0. "
            "Please use get_instance() instead."
        )

        return self.get_instance(instance_id).status

    def update_instance_status(self, instance_id, new_status):
        """
        .. deprecated: 3.0
            Will be removed in 4.0. Use ``update_instance()`` instead

        Get an Instance's status

        Args:
            instance_id (str): The Instance ID
            new_status (str): The new status

        Returns:
            Instance: The updated Instance

        """
        _deprecate(
            "This method is deprecated and scheduled to be removed in 4.0. "
            "Please use update_instance() instead."
        )

        return self.update_instance(instance_id, new_status=new_status)

    @wrap_response(return_boolean=True, default_exc=SaveError)
    def instance_heartbeat(self, instance_id):
        """Send an Instance heartbeat

        Args:
            instance_id (str): The Instance ID

        Returns:
            bool: True if the heartbeat was successful

        """
        return self.client.patch_instance(
            instance_id, SchemaParser.serialize_patch(PatchOperation("heartbeat"))
        )

    @wrap_response(return_boolean=True, default_exc=DeleteError)
    def remove_instance(self, instance_id):
        """Remove an Instance

        Args:
            instance_id (str): The Instance ID

        Returns:
            bool: True if the remove was successful

        """
        if instance_id is None:
            raise DeleteError("Cannot delete an instance without an id")

        return self.client.delete_instance(instance_id)

    @wrap_response(
        parse_method="parse_request", parse_many=False, default_exc=FetchError
    )
    def get_request(self, request_id):
        """Get a Request

        Args:
            request_id: The Id

        Returns:
            The Request

        """
        return self.client.get_request(request_id)

    def find_unique_request(self, **kwargs):
        """Find a unique request

        .. note::
            If 'id' is a given keyword argument then all other parameters will
            be ignored.

        Args:
            **kwargs: Search parameters

        Returns:
            Request, None: The Request if found, None otherwise

        Raises:
            FetchError: More than one matching Request was found

        """
        if "id" in kwargs:
            try:
                return self.get_request(kwargs.pop("id"))
            except NotFoundError:
                return None
        else:
            all_requests = self.find_requests(**kwargs)

            if not all_requests:
                return None

            if len(all_requests) > 1:
                raise FetchError("More than one matching Request found")

            return all_requests[0]

    @wrap_response(
        parse_method="parse_request", parse_many=True, default_exc=FetchError
    )
    def find_requests(self, **kwargs):
        """Find Requests using keyword arguments as search parameters

        Args:
            **kwargs: Search parameters

        Returns:
            List[Request]: List of Systems matching the search parameters

        """
        return self.client.get_requests(**kwargs)

    @wrap_response(
        parse_method="parse_request", parse_many=False, default_exc=SaveError
    )
    def create_request(self, request, **kwargs):
        """Create a new Request

        Args:
            request: New request definition
            **kwargs: Extra request parameters

        Keyword Args:
            blocking (bool): Wait for request to complete before returning
            timeout (int): Maximum seconds to wait for completion

        Returns:
            Request: The newly-created Request

        """
        return self.client.post_requests(
            SchemaParser.serialize_request(request), **kwargs
        )

    @wrap_response(
        parse_method="parse_request", parse_many=False, default_exc=SaveError
    )
    def update_request(self, request_id, status=None, output=None, error_class=None):
        """Update a Request

        Args:
            request_id (str): The Request ID
            status (Optional[str]): New Request status
            output (Optional[str]): New Request output
            error_class (Optional[str]): New Request error class

        Returns:
            Response: The updated response

        """
        operations = []

        if status:
            operations.append(PatchOperation("replace", "/status", status))
        if output:
            operations.append(PatchOperation("replace", "/output", output))
        if error_class:
            operations.append(PatchOperation("replace", "/error_class", error_class))

        return self.client.patch_request(
            request_id, SchemaParser.serialize_patch(operations, many=True)
        )

    @wrap_response(return_boolean=True)
    def publish_event(self, *args, **kwargs):
        """Publish a new event

        Args:
            *args: If a positional argument is given it's assumed to be an
                Event and will be used
            **kwargs: Will be used to construct a new Event to publish if no
                Event is given in the positional arguments

        Keyword Args:
            _publishers (Optional[List[str]]): List of publisher names.
                If given the Event will only be published to the specified
                publishers. Otherwise all publishers known to Beergarden will
                be used.

        Returns:
            bool: True if the publish was successful

        """
        publishers = kwargs.pop("_publishers", None)

        event = args[0] if args else Event(**kwargs)

        return self.client.post_event(
            SchemaParser.serialize_event(event), publishers=publishers
        )

    @wrap_response(parse_method="parse_queue", parse_many=True, default_exc=FetchError)
    def get_queues(self):
        """Retrieve all queue information

        Returns:
            List[Queue]: List of all Queues

        """
        return self.client.get_queues()

    @wrap_response(return_boolean=True, default_exc=DeleteError)
    def clear_queue(self, queue_name):
        """Cancel and remove all Requests from a message queue

        Args:
            queue_name (str): The name of the queue to clear

        Returns:
            bool: True if the clear was successful

        """
        return self.client.delete_queue(queue_name)

    @wrap_response(return_boolean=True, default_exc=DeleteError)
    def clear_all_queues(self):
        """Cancel and remove all Requests in all queues

        Returns:
            bool: True if the clear was successful

        """
        return self.client.delete_queues()

    @wrap_response(parse_method="parse_job", parse_many=True, default_exc=FetchError)
    def find_jobs(self, **kwargs):
        """Find Jobs using keyword arguments as search parameters

        Args:
            **kwargs: Search parameters

        Returns:
            List[Job]: List of Jobs matching the search parameters

        """
        return self.client.get_jobs(**kwargs)

    @wrap_response(parse_method="parse_job", parse_many=False, default_exc=SaveError)
    def create_job(self, job):
        """Create a new Job

        Args:
            job (Job): New Job definition

        Returns:
            Job: The newly-created Job

        """
        return self.client.post_jobs(SchemaParser.serialize_job(job))

    @wrap_response(return_boolean=True, default_exc=DeleteError)
    def remove_job(self, job_id):
        """Remove a unique Job

        Args:
            job_id (str): The Job ID

        Returns:
            bool: True if removal was successful

        Raises:
            DeleteError: Couldn't remove Job

        """
        return self.client.delete_job(job_id)

    def pause_job(self, job_id):
        """Pause a Job

        Args:
            job_id (str): The Job ID

        Returns:
            Job: The updated Job

        """
        return self._patch_job(job_id, [PatchOperation("update", "/status", "PAUSED")])

    def resume_job(self, job_id):
        """Resume a Job

        Args:
            job_id (str): The Job ID

        Returns:
            Job: The updated Job

        """
        return self._patch_job(job_id, [PatchOperation("update", "/status", "RUNNING")])

    @wrap_response(parse_method="parse_resolvable")
    def upload_bytes(self, data):
        # type: (bytes) -> Any
        """Upload a file

        Args:
            data: The bytes to upload

        Returns:
            The bytes Resolvable
        """
        return self.client.post_file(data)

    def download_bytes(self, file_id):
        # type: (str) -> bytes
        """Download bytes

        Args:
            file_id: Id of bytes to download

        Returns:
            The bytes data
        """
        return self.client.get_file(file_id).content

    @wrap_response(parse_method="parse_resolvable")
    def upload_file(self, path):
        # type: (Union[str, Path]) -> Any
        """Upload a file

        Args:
            path: Path to file

        Returns:
            The file Resolvable
        """
        # As of now this just converts the data param to bytes and uses the bytes API
        # Ideally this would fail-over to using the chunks API if necessary
        with open(path, "rb") as f:
            bytes_data = f.read()

        return self.client.post_file(bytes_data)

    def download_file(self, file_id, path):
        # type: (str, Union[str, Path]) -> Union[str, Path]
        """Download a file

        Args:
            file_id: The File id.
            path: Location for downloaded file

        Returns:
            Path to downloaded file
        """
        data = self.download_bytes(file_id)

        with open(path, "wb") as f:
            f.write(data)

        return path

    @wrap_response(parse_method="parse_resolvable")
    def upload_chunked_file(
        self, file_to_upload, desired_filename=None, file_params=None
    ):
        """Upload a given file to the Beer Garden server.

        Args:
            file_to_upload: Can either be an open file descriptor or a path.
            desired_filename: The desired filename, if none is provided it
            will use the basename of the file_to_upload
            file_params: The metadata surrounding the file.
                Valid Keys: See brewtils File model

        Returns:
            A BG file ID.

        """
        default_file_params = {}

        # Establish the file descriptor
        if isinstance(file_to_upload, six.string_types):
            try:
                fd = open(file_to_upload, "rb")
            except Exception:
                raise ValidationError("Could not open the requested file name.")
            require_close = True
        else:
            fd = file_to_upload
            require_close = False

        try:
            default_file_params["file_name"] = desired_filename or fd.name
        except AttributeError:
            default_file_params["file_name"] = "no_file_name_provided"

        # Determine the file size
        cur_cursor = fd.tell()
        default_file_params["file_size"] = fd.seek(0, 2) - cur_cursor
        fd.seek(cur_cursor)
        if file_params is not None:
            file_params["file_size"] = default_file_params["file_size"]

        # Set the parameters to be sent
        file_params = file_params or dict(
            default_file_params, **self._default_file_params
        )
        try:
            response = self.client.post_chunked_file(
                fd, file_params, current_position=cur_cursor
            )
            fd.seek(cur_cursor)
        finally:
            if require_close:
                fd.close()

        if not response.ok:
            handle_response_failure(response, default_exc=SaveError)

        # The file post is best effort; make sure to verify before we let the
        # user do anything with it
        file_id = response.json()["details"]["file_id"]

        valid, meta = self._check_chunked_file_validity(file_id)
        if not valid:
            # Clean up if you can
            self.client.delete_chunked_file(file_id)
            raise ValidationError(
                "Error occurred while uploading file %s"
                % default_file_params["file_name"]
            )

        return response

    def download_chunked_file(self, file_id):
        """Download a chunked file from the Beer Garden server.

        Args:
            file_id: The beer garden-assigned file id.

        Returns:
            A file object
        """
        (valid, meta) = self._check_chunked_file_validity(file_id)
        file_obj = BytesIO()
        if valid:
            for x in range(meta["number_of_chunks"]):
                resp = self.client.get_chunked_file(file_id, params={"chunk": x})
                if resp.ok:
                    data = resp.json()["data"]
                    file_obj.write(b64decode(data))
                else:
                    raise ValueError("Could not fetch chunk %d" % x)
        else:
            raise ValidationError("Requested file %s is incomplete." % file_id)

        file_obj.seek(0)

        return file_obj

    def delete_chunked_file(self, file_id):
        """Delete a given file on the Beer Garden server.

        Args:
            file_id: The beer garden-assigned file id.

        Returns:
            The API response
        """
        return self.client.delete_chunked_file(file_id)

    def forward(self, operation, **kwargs):
        """Forwards an Operation

        Args:
            operation: The Operation to be forwarded
            **kwargs: Keyword arguments to pass to Requests session call

        Returns:
            The API response

        """
        return self.client.post_forward(
            SchemaParser.serialize_operation(operation), **kwargs
        )

    @wrap_response(
        parse_method="parse_principal", parse_many=False, default_exc=FetchError
    )
    def get_user(self, user_identifier):
        """Find a user

        Args:
            user_identifier (str): User ID or username

        Returns:
            Principal: The User

        """
        return self.client.get_user(user_identifier)

    def who_am_i(self):
        """Find user using the current set of credentials

        Returns:
            Principal: The User

        """
        return self.get_user(self.client.username or "anonymous")

    @wrap_response(return_boolean=True)
    def rescan(self):
        """Rescan local plugin directory

        Returns:
            bool: True if rescan was successful

        """
        return self.client.patch_admin(
            payload=SchemaParser.serialize_patch(PatchOperation(operation="rescan"))
        )

    @wrap_response(return_boolean=True, default_exc=DeleteError)
    def _remove_system_by_id(self, system_id):
        if system_id is None:
            raise DeleteError("Cannot delete a system without an id")

        return self.client.delete_system(system_id)

    @wrap_response(parse_method="parse_job", parse_many=False, default_exc=SaveError)
    def _patch_job(self, job_id, operations):
        return self.client.patch_job(
            job_id, SchemaParser.serialize_patch(operations, many=True)
        )

    def _check_chunked_file_validity(self, file_id):
        """Verify a chunked file

        Args:
            file_id: The BG-assigned file id.

        Returns:
            A tuple containing the result and supporting metadata, if available
        """
        response = self.client.get_chunked_file(file_id, params={"verify": True})
        if not response.ok:
            return False, None

        metadata_json = response.json()

        if "valid" in metadata_json and metadata_json["valid"]:
            return True, metadata_json
        else:
            return False, metadata_json
