import asyncio
from collections import namedtuple
import logging
import time
import grpc

from preseem_grpc_model import metrics_pb2
from preseem.grpc_client import PreseemGrpcClient
from preseem.util import uint64

FlexMetric = namedtuple('FlexMetric', ('name', 'timestamp', 'labels', 'fields'))


class NetworkMetricsClient(PreseemGrpcClient):

    def __init__(self, api_key, host=None, port=None, beta=None):
        super().__init__(
            api_key, host or 'metrics{}.preseem.com'.format('-beta' if beta else ''),
            port)


class NetworkMetricsModel(object):
    _MAX_MESSAGE_SIZE = 1024 * 1024 * 2  # GRPC max message size is 4K but even getting close seems problematic?
    _MAX_LIST_LENGTH = 5000
    HOLDOFF_TIME = 10  # max seconds to wait to send a metrics batch

    def __init__(self, client):
        self._client = client
        self._flex_q = asyncio.Queue(5000)  # max size queue to limit memory
        self._flex_task = None
        self._last_q_full = None
        self._drop_count = 0

    def _flex_pb(self, flex):
        """Convert a FlexMetric object to a Flex protobuf message."""
        assert isinstance(flex, FlexMetric)
        assert isinstance(flex.labels, dict) and isinstance(flex.fields, dict)
        ts = metrics_pb2.google_dot_protobuf_dot_timestamp__pb2.Timestamp()
        ts.FromNanoseconds(int(flex.timestamp * 1000000000))
        obj = metrics_pb2.Flex(name=flex.name, timestamp=ts)
        for k, v in flex.labels.items():
            label = obj.labels.add()
            label.name = k
            label.value = v
        for f, v in flex.fields.items():
            if v is None:
                continue  # don't send null values
            tv = obj.fields[str(f)]
            tv.name = str(f)
            ty = type(v)
            if ty is bool:
                tv.type = metrics_pb2.TV.BOOL
                tv.bool_val = v
            elif ty is float:
                tv.type = metrics_pb2.TV.DOUBLE
                tv.double_val = v
            elif isinstance(v, str):
                tv.type = metrics_pb2.TV.STRING
                tv.string_val = v
            elif ty is int:
                if int.bit_length(v) > 63:
                    logging.debug(
                        "Flex: %s, labels: %s, field: %s's value: %s is bigger than max for INT64",
                        flex.name, flex.labels, f, v)
                    del obj.fields[str(f)]
                    continue
                tv.type = metrics_pb2.TV.INT64
                tv.int64_val = v
            elif ty is uint64:
                #STM-4895 - influxdb/big-query doesn't support uint64 therefore number bit length equals to or bigger than 64 are filtered
                if int.bit_length(v) >= 64:
                    logging.debug(
                        "Flex: %s, labels: %s, field: %s's value: %s is bigger than max for UINT64",
                        flex.name, flex.labels, f, v)
                    del obj.fields[str(f)]
                    continue
                if int(v) < 0:
                    logging.debug(
                        "Flex: %s, labels: %s, field: %s's value: %s cannot be negative for UINT64",
                        flex.name, flex.labels, f, v)
                    del obj.fields[str(f)]
                    continue
                tv.type = metrics_pb2.TV.UINT64
                tv.uint64_val = v
            else:
                del obj.fields[str(f)]
                raise RuntimeError('Unknown type for field {}: {}'.format(f, ty))
        return obj

    async def _service_flex_queue(self):
        """Task to consume the queue and write out flexes.  We try to batch as
           much as possible while respecting the message size and original
           batching, and without adding any delay."""
        try:
            loop = asyncio.get_event_loop()
            req = metrics_pb2.FlexPushRequest()
            size = req.ByteSize()
            buf = None  # extra flexes saved for next batch to send
            while True:
                if len(req.flexes):
                    try:
                        await self._client.push_flexes(req)
                        req = metrics_pb2.FlexPushRequest()
                        size = req.ByteSize()
                    except grpc.RpcError as rpc_error:
                        logging.warning('Error pushing flexes: %s', rpc_error)
                        await asyncio.sleep(self.HOLDOFF_TIME)
                        continue
                    except Exception as e:
                        # if it fails, we'll hold on to the failed message until
                        # it succeeds.  So we're assuming the message is ok or
                        # else we will never proceed past this.
                        logging.warning('Error pushing flexes: %s', e)
                        await asyncio.sleep(self.HOLDOFF_TIME)
                        continue
                try:
                    # Build a flex push request from the buffer and the queue
                    t = loop.time() + self.HOLDOFF_TIME
                    while True:
                        if buf:
                            pbs = buf
                            buf = None
                        else:
                            pbs = await asyncio.wait_for(self._flex_q.get(),
                                                         t - loop.time())
                            self._flex_q.task_done()
                        i = 0
                        for i, pb in enumerate(pbs):
                            if len(req.flexes) + i < self._MAX_LIST_LENGTH:
                                size += pb.ByteSize()
                                if size < self._MAX_MESSAGE_SIZE:
                                    continue
                            # send partway through the batch; buffer the rest
                            buf = pbs[i:]
                            del pbs[i:]
                            break
                        req.flexes.extend(pbs)
                        if buf or len(req.flexes) == self._MAX_LIST_LENGTH:
                            break
                except asyncio.TimeoutError:
                    pass  # we will send whatever we built on next loop iter
                except asyncio.CancelledError:
                    # try to send any in-progress message
                    try:
                        if len(req.flexes):
                            await self._client.push_flexes(req)
                    finally:
                        raise
        except asyncio.CancelledError:  # typical case
            pass
        except Exception as e:  # shouldn't happen but log if it does
            logging.info('Exiting _service_flex_queue due to %s', e)

    async def close(self):
        """close this model, cancel the background tasks."""
        if self._flex_task:
            self._flex_task.cancel()
            self._flex_task = None
        self._client = None

    async def push_flexes(self, flexes):
        """Push flex metrics.  The metrics are put on a queue and delivered
           asynchronously.  If the queue overflows they are logged out."""
        if not flexes:
            return
        assert isinstance(flexes, list)
        if not self._client:
            for flex in flexes:
                logging.warning('Metrics model closed, dropping flex: %s', flex)
            return

        if not self._flex_task:
            self._flex_task = asyncio.get_event_loop().create_task(
                self._service_flex_queue())
        try:
            self._flex_q.put_nowait([self._flex_pb(f) for f in flexes])
        except asyncio.QueueFull:  # send queue is full, drop the request
            now = time.monotonic()
            if not self._last_q_full or now - self._last_q_full > 300:
                self._last_q_full = now
                self._drop_count += len(flexes)
                logging.warning('Flex queue full, %s dropped', self._drop_count)
