from binascii import hexlify
from datetime import datetime
from functools import lru_cache
from time import sleep
from typing import Any, Dict, Generator, List, Optional

import requests
import simplejson as json
from deprecation import deprecated  # type: ignore

from pytezos.crypto.encoding import base58_decode
from pytezos.jupyter import get_attr_docstring
from pytezos.logging import logger
from pytezos.rpc.errors import ReorgError
from pytezos.rpc.kind import validation_passes
from pytezos.rpc.protocol import BlockQuery, BlocksQuery
from pytezos.rpc.query import RpcQuery
from pytezos.rpc.search import CyclesQuery, VotingPeriodsQuery

MAX_BLOCK_TIMEOUT = 86400


def make_operation_result(**kwargs):
    return {'metadata': {'operation_result': kwargs}}


class ShellQuery(RpcQuery, path=''):
    @property
    def blocks(self) -> BlocksQuery:
        """Shortcut for `chains.main.blocks`"""
        return self.chains.main.blocks

    @property
    def head(self) -> BlockQuery:
        """Shortcut for `blocks.head`"""
        return self.blocks.head

    @property  # type: ignore
    @lru_cache(maxsize=None)
    def block(self) -> BlockQuery:
        """Cached head block, useful if you just want to explore things."""
        return self.blocks[self.head.hash()]

    @property
    def cycles(self):
        """Operate on cycles rather than blocks."""
        return CyclesQuery(node=self.node, path=self._wild_path + '/chains/{}/blocks', params=self._params + ['main'])

    @property
    def voting_periods(self):
        """
        Operate on voting periods rather than blocks.
        """
        return VotingPeriodsQuery(node=self.node, path=self._wild_path + '/chains/{}/blocks', params=self._params + ['main'])

    @property
    def contracts(self):
        """Shortcut for `head.context.contracts`"""
        return self.head.context.contracts

    @property
    def mempool(self):
        """Shortcut for `chains.main.mempool`"""
        return self.chains.main.mempool

    def wait_blocks(
        self,
        current_block_hash: str,
        max_blocks: int = 1,
        yield_current=False,
        time_between_blocks: Optional[int] = None,
        block_timeout: Optional[int] = None,
    ) -> Generator[str, None, None]:
        """Iterates over future blocks (waits and yields block hash), handles reorgs

        :param current_block_hash: hash of the current block (head)
        :param max_blocks: number of blocks to iterate (not including the current one)
        :param yield_current: yield current block hash at the very beginning
        :param time_between_blocks: override protocol constant
        :param block_timeout: set block timeout (by default Pytezos will wait for a long time)
        :return: block hashes
        """
        if time_between_blocks is None:
            constants = self.blocks[current_block_hash].context.constants()
            time_between_blocks = int(constants.get('minimal_block_delay', 0))

        if block_timeout is None:
            block_timeout = MAX_BLOCK_TIMEOUT

        if yield_current:
            yield current_block_hash

        current_header = self.blocks[current_block_hash].header()
        max_level = current_header['level'] + max_blocks

        while current_header['level'] < max_level:
            logger.info('Current level: %d (max %d)', current_header['level'], max_level)
            prev_block_dt = datetime.strptime(current_header['timestamp'], '%Y-%m-%dT%H:%M:%SZ')
            elapsed_sec = (datetime.utcnow() - prev_block_dt).seconds
            sleep_sec = 1 if elapsed_sec > time_between_blocks else (time_between_blocks - elapsed_sec + 1)

            logger.info('Sleep %d seconds until block %s is superseded', sleep_sec, current_block_hash)
            sleep(sleep_sec)

            next_block_hash: Optional[str] = None

            for delay in range(block_timeout):
                next_block_hash = self.head.hash()
                if current_block_hash == next_block_hash:
                    sleep(1)
                else:
                    logger.info('Found new block %s (%d sec delay)', next_block_hash, delay)
                    break

            if current_block_hash != next_block_hash:
                assert next_block_hash
                yield next_block_hash
                current_block_hash = next_block_hash
                current_header = self.blocks[current_block_hash].header()
            else:
                raise TimeoutError('Reached timeout (%d sec) while waiting for the next block', block_timeout)

    def wait_operations(
        self,
        opg_hashes: List[str],
        ttl: int,
        min_confirmations: int,
        current_block_hash: Optional[str] = None,
        time_between_blocks: Optional[int] = None,
        block_timeout: Optional[int] = None,
    ) -> List[dict]:
        """Wait for one or many operations gain enough confirmations

        :param opg_hashes: list of operation hashes
        :param ttl: max time-to-live value (in mempool)
        :param min_confirmations: minimum number of blocks after inclusion to wait for
        :param current_block_hash: current block hash (head)
        :param time_between_blocks: override protocol constant
        :param block_timeout: set block timeout (by default Pytezos will wait for a long time)
        :return: list of operation contents with metadata
        """

        confirmations = dict()
        pending = set(opg_hashes)
        operations = list()
        block_hash = current_block_hash

        if block_hash is None:
            block_hash = self.head.hash()

        for block_hash in self.wait_blocks(
            current_block_hash=block_hash,
            max_blocks=ttl,
            yield_current=True,
            time_between_blocks=time_between_blocks,
            block_timeout=block_timeout,
        ):
            if len(pending) > 0:
                mempool = set(map(lambda x: x['hash'], self.mempool.pending_operations.flatten()))
                for opg_hash in opg_hashes:
                    if opg_hash in pending:
                        if opg_hash in mempool:
                            logger.info('Operation %s is still in mempool', opg_hash)
                        else:
                            logger.info('Operation %s has left mempool', opg_hash)
                            pending.remove(opg_hash)

            included = self.blocks[block_hash].operation_hashes()
            for i, vp in enumerate(included):
                for j, opg_hash in enumerate(vp):
                    if opg_hash in opg_hashes and opg_hash not in confirmations:
                        logger.info('Operation %s has been included to block %s', opg_hash, block_hash)
                        if opg_hash in pending:  # can be still in the particular node's mempool (not yet removed)
                            pending.remove(opg_hash)
                        confirmations[opg_hash] = 0  # initialize
                        operations.append(self.blocks[block_hash].operations[i][j]())

            for opg_hash in confirmations:
                confirmations[opg_hash] += 1
                logger.info('Operation %s has %d/%d confirmations', opg_hash, confirmations[opg_hash], min_confirmations)

            if len(operations) == len(opg_hashes):
                break

        if len(operations) < len(opg_hashes):
            raise StopIteration('Only %d of %d operations were included, stopping', len(operations), len(opg_hashes))

        for _ in self.wait_blocks(block_hash, max_blocks=min_confirmations - 1, time_between_blocks=time_between_blocks):
            for opg_hash in opg_hashes:
                confirmations[opg_hash] += 1
                logger.info('Operation %s has %d/%d confirmations', opg_hash, confirmations[opg_hash], min_confirmations)

        return operations

    @deprecated(deprecated_in='3.2.2', removed_in='4.0.0', details=f'Use wait_blocks() instead')
    def wait_next_block(
        self,
        delay_sec=1,
        prev_hash=None,
        time_between_blocks: Optional[int] = None,
        max_iterations: Optional[int] = None,
    ):
        """Wait until next block is finalized.

        :param prev_hash: Current block hash (optional). If not set, current head is used.
        :param time_between_blocks: override the corresponding parameter from constants
        :param max_iterations: Manually set the number of iterations
        :param delay_sec: Sleep delay
        """
        if time_between_blocks is None:
            time_between_blocks = int(self.block.context.constants()["minimal_block_delay"])  # type: ignore

        if time_between_blocks > 0:
            if prev_hash is None:
                header = self.head.header()
                prev_hash = header['hash']
                prev_block_dt = datetime.strptime(header['timestamp'], '%Y-%m-%dT%H:%M:%SZ')
                elapsed_sec = (datetime.utcnow() - prev_block_dt).seconds
                sleep_sec = 0 if elapsed_sec > time_between_blocks else time_between_blocks - elapsed_sec
            else:
                sleep_sec = time_between_blocks
            logger.info('Wait %s seconds until block %s is finalized', sleep_sec, prev_hash)
            sleep(sleep_sec)

        if max_iterations is None:
            max_iterations = max(1, time_between_blocks)

        for _ in range(max_iterations):
            current_block_hash = self.head.hash()
            if current_block_hash == prev_hash:
                sleep(delay_sec)
            else:
                return current_block_hash
        raise StopIteration("Timeout")

    def get_confirmations(self, opg_hash, kind, branch, head) -> int:
        """Returns the number of blocks applied after the operation was included in chain

        :param opg_hash: Operation group hash
        :param kind: Operation kind ('transaction', 'origination', etc)
        :param branch: Block ID one should stop the search at
        :param head: Block ID one should start the search from
        :return: Number of confirmations (0 if not found)
        """
        start = self.blocks[head].header()['level']
        stop = self.blocks[branch].header()['level']
        for level in range(start, stop, -1):
            vp = validation_passes[kind]
            hashes = self.blocks[level].operation_hashes[vp]()
            for idx, _hash in enumerate(hashes):
                if opg_hash == _hash:
                    _ = self.blocks[level].operations[vp, idx]()
                    logger.info('Operation %s was included in block %s', opg_hash, level)
                    return start - level + 1
        return 0


class ChainQuery(RpcQuery, path='/chains/{}'):
    def watermark(self):
        """Chain watermark, hex encoded."""
        data = self.chain_id()
        return hexlify(base58_decode(data.encode())).decode()


class InvalidBlockQuery(RpcQuery, path='/chains/{}/invalid_blocks/{}'):
    def delete(self):
        return self._delete()


class MempoolQuery(RpcQuery, path='/chains/{}/mempool'):
    def post(self, configuration):
        """Set operation filter rules.

        :param configuration: a JSON dictionary, known keys are `minimal_fees`, `minimal_nanotez_per_gas_unit`,
            `minimal_nanotez_per_byte`
        """
        return self._post(json=configuration)


class PendingOperationsQuery(RpcQuery, path='/chains/{}/mempool/pending_operations'):
    def __getitem__(self, item: str) -> Dict[str, Any]:
        """Search for operation in node's mempool by hash.

        :param item: operation group hash (base58)
        """
        operations_dict = self()
        for status, operations in operations_dict.items():
            for operation in operations:
                if isinstance(operation, dict):
                    if operation['hash'] == item:
                        return {
                            **make_operation_result(status=status),
                            **operation,
                        }
                elif isinstance(operation, list):
                    if operation[0] == item:
                        errors = operation[1].pop1('error', default=[])
                        return {
                            **make_operation_result(status=status, errors=errors),
                            **operation[1],
                            'hash': operation[0],
                        }
                else:
                    raise Exception('Unknown operation type', operation)
        raise StopIteration

    def flatten(self) -> List[Dict[str, Any]]:
        operations_dict = self()
        operations_list = list()
        for status, operations in operations_dict.items():
            for operation in operations:
                if isinstance(operation, dict):
                    operations_list.append(
                        {
                            **make_operation_result(status=status),
                            **operation,
                        }
                    )
                elif isinstance(operation, list):
                    errors = operation[1].pop('error', [])
                    operations_list.append(
                        {
                            **make_operation_result(status=status, errors=errors),
                            **operation[1],
                            'hash': operation[0],
                        }
                    )
                else:
                    raise Exception('Unknown operation type', operation)
        return operations_list

    def __repr__(self):
        res = [
            super(PendingOperationsQuery, self).__repr__(),
            '[]' + get_attr_docstring(self.__class__, '__getitem__'),
        ]
        return '\n'.join(res)


class DescribeQuery(RpcQuery, path='/describe'):
    def __call__(self, recurse=True):
        """Get RPCs documentation and input/output schema.

        :param recurse: Show information for child elements, default is True.
            In some cases doesn't work without this flag.
        """
        return super(DescribeQuery, self).__call__(recurse=recurse)

    def __repr__(self):
        res = [
            super(DescribeQuery, self).__repr__(),
            f'(){get_attr_docstring(DescribeQuery, "__call__")}',
            'Can be followed by any path:\n.chains\n.network.connections\netc\n',
        ]
        return '\n'.join(res)


class BlockInjectionQuery(RpcQuery, path='/injection/block'):
    def post(self, block, timestamp=None, _async=False, force=False, chain=None):
        """Inject a block in the node and broadcast it.

        The `operations` embedded in `blockHeader` might be pre-validated using a contextual RPCs from the latest block
        (e.g. '/blocks/head/context/preapply').

        block format:

        .. code-block:: python

            {
                "data": <hex-encoded block header>,
                "operations": [ [ {
                "branch": <block_hash>,
                "data": <hex-encoded operation>
                } ... ] ... ]
            }

        :param block: JSON input
        :param _async: By default, the RPC will wait for the block to be validated before answering, \
        set True if you don't want to.
        :param force:
        :param chain: Optionally you can specify the chain (main/test)
        :returns: ID of the block
        """
        return self._post(
            params={
                'async': _async,
                'force': force,
                'chain': chain,
                'timestamp': timestamp,
            },
            json=block,
        )


class OperationInjectionQuery(RpcQuery, path='/injection/operation'):
    def post(self, operation, _async=False, chain=None):
        """Inject an operation in node and broadcast it.
        The `signedOperationContents` should be constructed using a contextual RPCs from the latest block
        and signed by the client.

        :param operation: Hex-encoded operation data or bytes
        :param _async: By default, the RPC will wait for the operation to be (pre-)validated before answering,
            set True if you don't want to.
        :param chain: Optionally you can specify the chain
        :returns: ID of the operation
        """
        if isinstance(operation, bytes):
            operation = operation.hex()

        return self._post(
            params={
                'async': _async,
                'chain': chain,
            },
            json=operation,
        )


class ProtocolInjectionQuery(RpcQuery, path='/injection/protocol'):
    def post(self, protocol, _async=False, force=False):
        """Inject a protocol in node.

        protocol format:

        .. code-block:: python

            {
                "expected_env_version": <integer>,
                "components": [{
                    "name": <unistring>,
                    "interface"?: <hex-encoded data>,
                    "implementation": <hex-encoded data> }
                     ...
                ]}
            }

        :param protocol: JSON input
        :param _async:
        :param force:
        :returns: ID of the protocol
        """
        return self._post(
            params={
                'async': _async,
                'force': force,
            },
            json=protocol,
        )


class ResponseGenerator:
    def __init__(self, res: requests.Response):
        self._lines = res.iter_lines()

    def __iter__(self):
        for line in self._lines:
            yield json.loads(line.decode())


class MonitorQuery(
    RpcQuery,
    path=[
        '/monitor/active_chains',
        '/monitor/bootstrapped',
        '/monitor/commit_hash',
        '/monitor/heads/{}',
        '/monitor/protocols',
        '/monitor/valid_blocks',
    ],
):
    def __call__(self, *args, **kwargs):
        return ResponseGenerator(
            self.node.request(
                method='GET',
                path=self.path,
                params=kwargs,
                stream=True,
            )
        )

    def __repr__(self):
        res = [
            super(MonitorQuery, self).__repr__(),
            'NOTE: Returned object is a generator.',
        ]
        return '\n'.join(res)


class ConnectionQuery(RpcQuery, path='/network/connections/{}'):
    def delete(self, wait=False):
        return self._delete(params=dict(wait=wait))


class NetworkItems(RpcQuery, path=['/network/peers', '/network/points']):
    def __call__(self, _filter=None):
        return self._get(params={'filter': _filter})


class NetworkLogQuery(RpcQuery, path=['/network/peers/{}/log', '/network/points/{}/log']):
    def __call__(self, monitor=False):
        if monitor:
            return ResponseGenerator(
                self.node.request(
                    method='GET',
                    path=self.path,
                    stream=True,
                )
            )
        return self._get()
