#  Copyright 2016-2022. Couchbase, Inc.
#  All Rights Reserved.
#
#  Licensed under the Apache License, Version 2.0 (the "License")
#  you may not use this file except in compliance with the License.
#  You may obtain a copy of the License at
#
#      http://www.apache.org/licenses/LICENSE-2.0
#
#  Unless required by applicable law or agreed to in writing, software
#  distributed under the License is distributed on an "AS IS" BASIS,
#  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
#  See the License for the specific language governing permissions and
#  limitations under the License.

import asyncio
import json
from datetime import timedelta
from enum import Enum
from typing import (Any,
                    Dict,
                    List,
                    Optional)

from couchbase.exceptions import ErrorMapper, InvalidArgumentException
from couchbase.exceptions import exception as CouchbaseBaseException
from couchbase.logic.options import AnalyticsOptionsBase
from couchbase.options import AnalyticsOptions, UnsignedInt64
from couchbase.pycbc_core import analytics_query
from couchbase.serializer import DefaultJsonSerializer, Serializer


class AnalyticsScanConsistency(Enum):
    """
    For use with :attr:`~.AnalyticsQuery.consistency`, will allow cached
    values to be returned. This will improve performance but may not
    reflect the latest data in the server.
    """
    NOT_BOUNDED = "not_bounded"
    REQUEST_PLUS = "request_plus"


class AnalyticsStatus(Enum):
    RUNNING = ()
    SUCCESS = ()
    ERRORS = ()
    COMPLETED = ()
    STOPPED = ()
    TIMEOUT = ()
    CLOSED = ()
    FATAL = ()
    ABORTED = ()
    UNKNOWN = ()


class AnalyticsProblem(object):
    def __init__(self, raw):
        self._raw = raw

    def code(self) -> int:
        return self._raw.get("code", None)

    def message(self) -> str:
        return self._raw.get("message", None)


class AnalyticsWarning(AnalyticsProblem):
    def __init__(self, analytics_warning):
        super().__init__(analytics_warning)

    def __repr__(self):
        return "AnalyticsWarning:{}".format(super()._raw)


class AnalyticsError(AnalyticsProblem):
    def __init__(self, analytics_error):
        super().__init__(analytics_error)

    def __repr__(self):
        return "AnalyticsError:{}".format(super()._raw)


class AnalyticsMetrics(object):

    def __init__(self, raw  # type: Dict[str, Any]
                 ) -> None:
        self._raw = raw

    @property
    def _raw_metrics(self):
        return self._raw

    def elapsed_time(self) -> timedelta:
        us = self._raw.get("elapsed_time") / 1000
        return timedelta(microseconds=us)

    def execution_time(self) -> timedelta:
        us = self._raw.get("execution_time") / 1000
        return timedelta(microseconds=us)

    def result_count(self) -> UnsignedInt64:
        return UnsignedInt64(self._raw.get("result_count", 0))

    def result_size(self) -> UnsignedInt64:
        return UnsignedInt64(self._raw.get("result_size", 0))

    def error_count(self) -> UnsignedInt64:
        return UnsignedInt64(self._raw.get("error_count", 0))

    def processed_objects(self) -> UnsignedInt64:
        return UnsignedInt64(self._raw.get("processed_objects", 0))

    def warning_count(self) -> UnsignedInt64:
        return UnsignedInt64(self._raw.get("warning_count", 0))

    def __repr__(self):
        return "AnalyticsMetrics:{}".format(self._raw)


class AnalyticsMetaData:
    def __init__(self, raw  # type: Dict[str, Any]
                 ) -> None:
        if raw is not None:
            self._raw = raw.get('metadata', None)
        else:
            self._raw = None

    def request_id(self) -> str:
        return self._raw.get("request_id", None)

    def client_context_id(self) -> str:
        return self._raw.get("client_context_id", None)

    def status(self) -> AnalyticsStatus:
        return AnalyticsStatus[self._raw.get("status", "unknown").upper()]

    def signature(self) -> Optional[Dict[str, Any]]:
        return self._raw.get("signature", None)

    def warnings(self) -> List[AnalyticsWarning]:
        return list(
            map(AnalyticsWarning, self._raw.get("warnings", []))
        )

    def errors(self) -> List[AnalyticsError]:
        return list(
            map(AnalyticsError, self._raw.get("errors", []))
        )

    def metrics(self) -> Optional[AnalyticsMetrics]:
        if "metrics" in self._raw:
            return AnalyticsMetrics(self._raw.get("metrics", {}))
        return None

    def __repr__(self):
        return "AnalyticsMetaData:{}".format(self._raw)


class AnalyticsQuery:

    _VALID_OPTS = {
        'timeout': {'timeout': timedelta.seconds},
        'read_only': {'readonly': lambda x: x},
        'scan_consistency': {'consistency': lambda x: x.value},
        'client_context_id': {'client_context_id': lambda x: x},
        'priority': {'priority': lambda x: x},
        'query_context': {'query_context': lambda x: x},
        'serializer': {'serializer': lambda x: x},
        'raw': {'raw': lambda x: x},
        'positional_parameters': {},
        'named_parameters': {}
    }

    def __init__(self, query, *args, **kwargs):

        self._adhoc = True
        self._params = {"statement": query}
        self._raw = None
        if args:
            self._add_pos_args(*args)
        if kwargs:
            self._set_named_args(**kwargs)

    def _set_named_args(self, **kv):
        """
        Set a named parameter in the query. The named field must
        exist in the query itself.

        :param kv: Key-Value pairs representing values within the
            query. These values should be stripped of their leading
            `$` identifier.

        """
        # named_params = {}
        # for k in kv:
        #     named_params["${0}".format(k)] = json.dumps(kv[k])
        # couchbase++ wants all args JSONified
        named_params = {f'${k}': json.dumps(v) for k, v in kv.items()}

        self._params["named_parameters"] = named_params
        return self

    def _add_pos_args(self, *args):
        """
        Set values for *positional* placeholders (``$1,$2,...``)

        :param args: Values to be used
        """
        arg_array = self._params.setdefault("positional_parameters", [])
        # couchbase++ wants all args JSONified
        json_args = [json.dumps(arg) for arg in args]
        arg_array.extend(json_args)

    def set_option(self, name, value):
        """
        Set a raw option in the query. This option is encoded
        as part of the query parameters without any client-side
        verification. Use this for settings not directly exposed
        by the Python client.

        :param name: The name of the option
        :param value: The value of the option
        """
        self._params[name] = value

    @property
    def params(self):
        return self._params

    @property
    def metrics(self):
        return self._params.get("metrics", True)

    @metrics.setter
    def metrics(self, value):
        self.set_option("metrics", value)

    @property
    def priority(self):
        return self._params.get("priority", False)

    @priority.setter
    def priority(self, value):
        self.set_option("priority", value)

    @property
    def statement(self):
        return self._params["statement"]

    @property
    def consistency(self):
        return self._params.get(
            "scan_consistency", AnalyticsScanConsistency.NOT_BOUNDED.value
        )

    @consistency.setter
    def consistency(self, value):
        self._params["scan_consistency"] = value

    @property
    def readonly(self):
        value = self._params.get("readonly", False)
        return value

    @readonly.setter
    def readonly(self, value):
        self._params["readonly"] = value

    @property
    def query_context(self) -> Optional[str]:
        return self._params.get('scope_qualifier', None)

    @query_context.setter
    def query_context(self, value  # type: str
                      ) -> None:
        self.set_option('scope_qualifier', value)

    @property
    def serializer(self):
        return self._params.get("serializer", None)

    @serializer.setter
    def serializer(self, value):
        if not issubclass(value, Serializer):
            raise InvalidArgumentException('Serializer should implement Serializer interface.')
        self._params["serializer"] = value

    @property
    def raw(self) -> Optional[Dict[str, Any]]:
        return self._params.get('raw', None)

    @raw.setter
    def raw(self, value  # type: Dict[str, Any]
            ) -> None:
        if not isinstance(value, dict):
            raise TypeError("Raw option must be of type Dict[str, Any].")
        for k in value.keys():
            if not isinstance(k, str):
                raise TypeError("key for raw value must be str")
        raw_params = {f'{k}': json.dumps(v) for k, v in value.items()}
        self.set_option('raw', raw_params)

    @classmethod
    def create_query_object(cls, statement, *options, **kwargs):
        # lets make a copy of the options, and update with kwargs...
        opt = AnalyticsOptions()
        # TODO: is it possible that we could have [AnalyticsOptions, AnalyticsOptions, ...]??
        #       If so, why???
        opts = list(options)
        for o in opts:
            if isinstance(o, (AnalyticsOptions, AnalyticsOptionsBase)):
                opt = o
                opts.remove(o)
        args = opt.copy()
        args.update(kwargs)

        # now lets get positional parameters.  Actual positional
        # params OVERRIDE positional_parameters
        positional_parameters = args.pop("positional_parameters", [])
        if opts and len(opts) > 0:
            positional_parameters = opts

        # now the named parameters.  NOTE: all the kwargs that are
        # not VALID_OPTS must be named parameters, and the kwargs
        # OVERRIDE the list of named_parameters
        new_keys = list(filter(lambda x: x not in cls._VALID_OPTS, args.keys()))
        named_parameters = args.pop("named_parameters", {})
        for k in new_keys:
            named_parameters[k] = args[k]

        query = cls(statement, *positional_parameters, **named_parameters)
        # now lets try to setup the options.
        # but for now we will use the existing N1QLQuery.  Could be we can
        # add to it, etc...

        # default to false on metrics
        query.metrics = args.get("metrics", True)

        for k, v in ((k, args[k]) for k in (args.keys() & cls._VALID_OPTS)):
            for target, transform in cls._VALID_OPTS[k].items():
                setattr(query, target, transform(v))
        return query


class AnalyticsRequestLogic:
    def __init__(self,
                 connection,
                 query_params,
                 row_factory=lambda x: x,
                 **kwargs
                 ):

        self._connection = connection
        self._query_params = query_params
        self.row_factory = row_factory
        self._rows = asyncio.Queue()
        self._streaming_result = None
        self._default_serializer = kwargs.pop('default_serializer', DefaultJsonSerializer())
        self._serializer = None
        self._started_streaming = False
        self._done_streaming = False
        self._metadata = None

    @property
    def params(self) -> Dict[str, Any]:
        return self._query_params

    @property
    def serializer(self) -> Serializer:
        if self._serializer:
            return self._serializer

        serializer = self.params.get('serializer', None)
        if not serializer:
            serializer = self._default_serializer

        self._serializer = serializer
        return self._serializer

    @property
    def started_streaming(self) -> bool:
        return self._started_streaming

    @property
    def done_streaming(self) -> bool:
        return self._done_streaming

    def metadata(self):
        # @TODO:  raise if query isn't complete?
        return self._metadata

    def _set_metadata(self, analytics_response):
        if isinstance(analytics_response, CouchbaseBaseException):
            raise ErrorMapper.build_exception(analytics_response)

        self._metadata = AnalyticsMetaData(analytics_response.raw_result.get('value', None))

    def _submit_query(self, **kwargs):
        if self.done_streaming:
            return

        self._started_streaming = True
        analytics_kwargs = {
            'conn': self._connection,
        }
        analytics_kwargs.update(self.params)

        # this is for txcouchbase...
        callback = kwargs.pop('callback', None)
        if callback:
            analytics_kwargs['callback'] = callback

        errback = kwargs.pop('errback', None)
        if errback:
            analytics_kwargs['errback'] = errback

        self._streaming_result = analytics_query(**analytics_kwargs)

    def __iter__(self):
        raise NotImplementedError(
            'Cannot use synchronous iterator, are you using `async for`?'
        )

    def __aiter__(self):
        raise NotImplementedError(
            'Cannot use asynchronous iterator.'
        )
