import asyncio
import logging
from typing import Dict, Optional, Tuple, List

import wait_for2
from clarabot_crypto.group_config.block import GroupConfigBlock, GroupConfigBlockFlags, GroupConfigBlockMemberFlags
from clarabot_crypto.group_config.chain import GroupConfigBlockChain
from clarabot_crypto.keyring.account_base import AbstractSelfAccountKeyring

from clarabot_nano.common.error import maybe_rad32
from clarabot_nano.core.config.utility import block_name_to_config_format, block_name_from_config_format

logger = logging.getLogger(__name__)


class RoomGroupConfigNotReady(Exception):
    @property
    def state(self):
        return self.args[0]


class RoomGroupConfigState(object):

    READY_WAIT_BY_FLAGS = 15.0

    STATE_LOADING = 1  # serve-granted, config-loading is in progress
    STATE_UNVERIFIED = 2  # serving is possible, but the blockchain binding is not verified by the owner or it broke
    STATE_READY = 3  # blockchain is loaded and is supposedly up to date
    # higher states are final
    STATE_REVOKED = 4  # serving is no longer possible because of revocation of session or config
    STATE_ERROR = 5  # failed to load a block or the chain

    def __init__(self, room_id: int):
        self._room_id = room_id
        # usability state of the blockchain
        self._ready = asyncio.Event()
        self.state = self.STATE_LOADING
        # self._init_event_mutex = asyncio.Lock()
        self._init_raw_blocks: List[Tuple[bytes, Optional[bytes]]] = []
        self._blockchain: GroupConfigBlockChain = None

    def needs_to_be_loaded(self) -> bool:
        return self.state == RoomGroupConfigState.STATE_LOADING

    def ready_for_use(self) -> bool:
        return self.state == RoomGroupConfigState.STATE_READY

    def state_is_final(self) -> bool:
        return self.state >= self.STATE_REVOKED

    def get_top_verified_blocks(self) -> Optional[List[str]]:
        """
        If the blockchain is loaded and verified (ready) then the head blocks are always at least as new as
        the verified blocks so returning them is safe.
        """
        if self.state == RoomGroupConfigState.STATE_READY:
            return sorted(
                block_name_to_config_format(self._blockchain.create_block_name(hb))
                for hb in self._blockchain.head_blocks
            )

    def close(self):
        """
        Notify waiting tasks that this will not be loaded, ever.
        """
        if self.state < self.STATE_REVOKED:
            self.state = self.STATE_REVOKED
        self._ready.set()  # notify waiters

    def add_block(self, raw_block: bytes, signature: Optional[bytes], owner_kr: AbstractSelfAccountKeyring):
        """
        Buffer a new block before/during loading the chain, or add a block to the loaded chain.
        """
        if self._blockchain is None:
            self._init_raw_blocks.append((raw_block, signature))
        else:
            # This is an update to an already loaded chain. Similarly to how errors are all caught at load we,
            # do the same here and set the state flag to ERROR to prevent further usage. Later if reload/retry
            # of failed chains is implemented this shall also trigger that eventually.
            try:
                self._blockchain.load_block(raw_block, signature, owner_kr)
            except Exception:
                self.state = self.STATE_ERROR
                logger.exception("Error while loading room blockchain")

    async def new_block(
        self,
        owner_kr: AbstractSelfAccountKeyring,
        block_id: int,
        member_flags: Optional[Dict[int, int]],
        group_flags: Optional[int],
    ):
        await self.wait_ready()
        return self._blockchain.new_block(owner_kr, block_id, member_flags, group_flags)

    def load_init(self, owner_kr: AbstractSelfAccountKeyring, verified_room_blocks: List[str]):
        """
        Process all gathered blocks and load the chain. If the loading is successful the state will also reflect
        if the chain is verified for use.
        """
        if self.state != self.STATE_LOADING:
            raise Exception("Invalid state for loading config blockchain")
        try:
            self._blockchain = GroupConfigBlockChain(self._room_id, self._init_raw_blocks, owner_kr)
            self._init_raw_blocks = None  # free
        except Exception:
            self.state = self.STATE_ERROR
            logger.exception("Error while loading room blockchain")
        else:
            self.state = self.STATE_UNVERIFIED  # enter loaded (unverified or read) state
            self.update_verification(verified_room_blocks)
        finally:
            self._ready.set()  # notify waiters

    def update_verification(self, verified_room_blocks: List[str]):
        if self.state < self.STATE_UNVERIFIED or self.state >= self.STATE_REVOKED:
            # TODO: this propagates to main-tasks and terminates the gateway... better handling?
            raise Exception("Invalid state for updating config blockchain verification")
        new_state = self.STATE_UNVERIFIED
        if verified_room_blocks:
            for verified_room_block in verified_room_blocks:
                block_name = block_name_from_config_format(verified_room_block)
                if not block_name:
                    break
                if not self._blockchain.has_block(block_name):
                    break
            else:
                new_state = self.STATE_READY
        self.state = new_state

    async def wait_ready(self):
        if not self._ready.is_set():
            try:
                await wait_for2.wait_for(self._ready.wait(), self.READY_WAIT_BY_FLAGS)
            except asyncio.TimeoutError:
                pass
        if self.state != self.STATE_READY:
            raise RoomGroupConfigNotReady(
                self.state, f"room group-config of {maybe_rad32(self._room_id)} is not available: {self.state}"
            )

    async def get_flags(self, account_id: int) -> Optional[Tuple[int, int]]:
        """
        Get the current permissions of an account by the head of the group-config blockchain of the resource.
        The returned tuple consists of the account's permission flags and the group flags as second.
        When there are multiple heads the flags are merged as the blockchain would resolve them with an empty block.
        If the requesting account is not a member nor has anonymous access the flags will not be returned. This means
        the permissions are evaluated here and only returned if some are granted to the account. When the account is
        not an active member the returned member-flags are exactly zero.
        """
        await self.wait_ready()
        # NOTE: No more awaits so the rest is executed uninterrupted.
        head_blocks = self._blockchain.head_blocks
        if len(head_blocks) == 1:
            member_flags = head_blocks[0][GroupConfigBlock.PROC_MEMBER_FLAGS].get(account_id, 0)
            group_flags = head_blocks[0][GroupConfigBlock.PROC_GROUP_FLAGS]
        elif not head_blocks:
            return
        else:
            # Merge as the blockchain would with a new empty block.
            member_flags = 0
            group_flags = 0
            for block in head_blocks:
                member_flags |= block[GroupConfigBlock.PROC_MEMBER_FLAGS].get(account_id, 0)
                group_flags |= block[GroupConfigBlock.PROC_GROUP_FLAGS]
            member_flags = GroupConfigBlock.finalize_flags_merge(member_flags)
            group_flags = GroupConfigBlock.finalize_flags_merge(group_flags)
        # Check if account has membership or the room is accessible anonymously for returning the flags.
        # NOTE: The extra account_id equals zero case prevents a strange edge case from manifesting. When the
        # anonymous account representing id is added as a member to the blockchain, the anonymous users would
        # be seen as effective members. This is not valid, the server will not accept new blocks that have such
        # assertions, but the client must avoid this for safety anyways. Only a rouge member with administrator
        # permission could make this edge case manifest aside from the owner themselves.
        if not (member_flags & GroupConfigBlockMemberFlags.ACTIVE) or account_id == 0:
            if group_flags & GroupConfigBlockFlags.ALLOW_ANONYMOUS:
                return 0, group_flags  # simplify the member flags for account that is not an active member
        else:  # account is member
            return member_flags, group_flags
