from typing import Dict
from typing import List
from typing import Union

from .model import Block
from .model import DuplicateBlockKeyBlock
from .model import Entry
from .model import ExplicitComment
from .model import ImplicitComment
from .model import ParsingFailedBlock
from .model import Preamble
from .model import String

# TODO Use functools.lru_cache for library properties (which create lists when called)


class Library:
    """A collection of parsed bibtex blocks."""

    def __init__(self, blocks: Union[List[Block], None] = None):
        self._blocks = []
        self._entries_by_key = dict()
        self._strings_by_key = dict()
        if blocks is not None:
            self.add(blocks)

    def add(self, blocks: Union[List[Block], Block], fail_on_duplicate_key: bool = False):
        """Add blocks to library.

        The adding is key-safe, i.e., it is made sure that no duplicate keys are added.
        for the same type (i.e., String or Entry). Duplicates are silently replaced with
        a DuplicateKeyBlock.

        :param blocks: Block or list of blocks to add.
        :param fail_on_duplicate_key:
            If True, raises ValueError if a block was replaced with a DuplicateKeyBlock.
        """
        if isinstance(blocks, Block):
            blocks = [blocks]

        _added_blocks = []
        for block in blocks:
            # This may replace block with a DuplicateEntryKeyBlock
            block = self._add_to_dicts(block)
            self._blocks.append(block)
            _added_blocks.append(block)

        if fail_on_duplicate_key:
            duplicate_keys = []
            for original, added in zip(blocks, _added_blocks):
                if original is not added and isinstance(added, DuplicateBlockKeyBlock):
                    duplicate_keys.append(added.key)

            if len(duplicate_keys) > 0:
                raise ValueError(
                    f"Duplicate keys found: {duplicate_keys}. "
                    f"Duplicate entries have been added to the library as DuplicateBlockKeyBlock."
                    f"Use `library.failed_blocks` to access them. "
                )

    def remove(self, blocks: Union[List[Block], Block]):
        """Remove blocks from library.

        :param blocks: Block or list of blocks to remove.
        :raises ValueError: If block is not in library."""
        if isinstance(blocks, Block):
            blocks = [blocks]

        for block in blocks:
            self._blocks.remove(block)
            if isinstance(block, Entry):
                del self._entries_by_key[block.key]
            elif isinstance(block, String):
                del self._strings_by_key[block.key]

    def replace(self, old_block: Block, new_block: Block, fail_on_duplicate_key: bool = True):
        """Replace a block with another block, at the same position.

        :param old_block: Block to replace.
        :param new_block: Block to replace with.
        :param fail_on_duplicate_key: If False, adds a DuplicateKeyBlock if
                a block with new_block.key (other than old_block) already exists.
        :raises ValueError: If old_block is not in library or if fail_on_duplicate_key is True
                and a block with new_block.key (other than old_block) already exists."""
        try:
            index = self._blocks.index(old_block)
            self.remove(old_block)
        except ValueError:
            raise ValueError("Block to replace is not in library.")

        block_after_add = self._add_to_dicts(new_block)
        self._blocks.insert(index, block_after_add)

        if (
            new_block is not block_after_add
            and isinstance(block_after_add, DuplicateBlockKeyBlock)
            and fail_on_duplicate_key
        ):
            # Revert changes to old_block
            #   Don't fail on duplicate key, as this would lead to an infinite recursion
            #   (should never happen for a clean library, but could happen if the user
            #   tampered with the internals of the library).
            self.replace(block_after_add, old_block, fail_on_duplicate_key=False)
            raise ValueError("Duplicate key found.")

    @staticmethod
    def _cast_to_duplicate(
        prev_block_with_same_key: Union[Entry, String], duplicate: Union[Entry, String]
    ):
        assert isinstance(prev_block_with_same_key, type(duplicate)) or isinstance(
            duplicate, type(prev_block_with_same_key)
        ), (
            "Internal BibtexParser Error. Duplicate blocks share no common type."
            f"Found {type(prev_block_with_same_key)} and {type(duplicate)}, but both should be"
            f"either instance of String or instance of Entry."
            f"Please report this issue at the bibtexparser issue tracker.",
        )

        assert (
            prev_block_with_same_key.key == duplicate.key
        ), "Internal BibtexParser Error. Duplicate blocks have different keys."

        return DuplicateBlockKeyBlock(
            start_line=duplicate.start_line,
            raw=duplicate.raw,
            key=duplicate.key,
            previous_block=prev_block_with_same_key,
            duplicate_block=duplicate,
        )

    def _add_to_dicts(self, block):
        """Safely add block references to private dict structures.

        :param block: Block to add.
        :returns: The block that was added to the library. If a block
            of same type and with same key already existed, a
            DuplicateKeyBlock is returned (not added to dict).
        """
        if isinstance(block, Entry):
            try:
                prev_block_with_same_key = self._entries_by_key[block.key]
                block = self._cast_to_duplicate(prev_block_with_same_key, block)
            except KeyError:
                # No duplicate found
                self._entries_by_key[block.key] = block
        elif isinstance(block, String):
            try:
                prev_block_with_same_key = self._strings_by_key[block.key]
                block = self._cast_to_duplicate(prev_block_with_same_key, block)
            except KeyError:
                # No duplicate found
                self._strings_by_key[block.key] = block
        return block

    @property
    def blocks(self) -> List[Block]:
        """All blocks in the library, preserving order of insertion."""
        return self._blocks

    @property
    def failed_blocks(self) -> List[ParsingFailedBlock]:
        """All blocks that could not be parsed, preserving order of insertion."""
        return [b for b in self._blocks if isinstance(b, ParsingFailedBlock)]

    @property
    def strings(self) -> List[String]:
        """All @string blocks in the library, preserving order of insertion."""
        return list(self._strings_by_key.values())

    @property
    def strings_dict(self) -> Dict[str, String]:
        """Dict representation of all @string blocks in the library."""
        return self._strings_by_key

    @property
    def entries(self) -> List[Entry]:
        """All entry (@article, ...) blocks in the library, preserving order of insertion."""
        # Note: Taking this from the entries dict would be faster, but does not preserve order
        #   e.g. in cases where `replace` has been called.
        return [b for b in self._blocks if isinstance(b, Entry)]

    @property
    def entries_dict(self) -> Dict[str, Entry]:
        """Dict representation of all entry blocks in the library."""
        return self._entries_by_key.copy()

    @property
    def preambles(self) -> List[Preamble]:
        """All @preamble blocks in the library, preserving order of insertion."""
        return [block for block in self._blocks if isinstance(block, Preamble)]

    @property
    def comments(self) -> List[Union[ExplicitComment, ImplicitComment]]:
        """All comment blocks in the library, preserving order of insertion."""
        return [
            block for block in self._blocks if isinstance(block, (ExplicitComment, ImplicitComment))
        ]
