import os
import typing as t
import datetime
from abc import ABC
from abc import abstractmethod
from contextlib import contextmanager

import fs
import fs.errors
from fs.base import FS

from .types import Tag
from .types import PathType
from ..exceptions import NotFound
from ..exceptions import BentoMLException

T = t.TypeVar("T")


class StoreItem(ABC):
    @property
    @abstractmethod
    def tag(self) -> Tag:
        raise NotImplementedError

    @classmethod
    @abstractmethod
    def from_fs(cls: t.Type[T], item_fs: FS) -> T:
        raise NotImplementedError

    @property
    @abstractmethod
    def creation_time(self) -> datetime.datetime:
        raise NotImplementedError

    def __repr__(self):
        return f'{self.__class__.__name__}(tag="{self.tag}")'


Item = t.TypeVar("Item", bound=StoreItem)


class Store(ABC, t.Generic[Item]):
    """An FsStore manages items under the given base filesystem.

    Note that FsStore has no consistency checks; it assumes that no direct modification
    of the files in its directory has occurred.

    """

    _fs: FS
    _item_type: t.Type[Item]

    @abstractmethod
    def __init__(self, base_path: t.Union[PathType, FS], item_type: t.Type[Item]):
        self._item_type = item_type
        if isinstance(base_path, os.PathLike):
            base_path = base_path.__fspath__()
        self._fs = fs.open_fs(base_path)

    def list(self, tag: t.Optional[t.Union[Tag, str]] = None) -> t.List[Item]:
        if not tag:
            return [
                ver for _d in sorted(self._fs.listdir("/")) for ver in self.list(_d)
            ]

        _tag = Tag.from_taglike(tag)
        if _tag.version is None:
            if not self._fs.isdir(_tag.name):
                raise NotFound(
                    f"no {self._item_type.__name__}s with name '{_tag.name}' found"
                )

            tags = sorted(
                [
                    Tag(_tag.name, f.name)
                    for f in self._fs.scandir(_tag.name)
                    if f.is_dir
                ]
            )
            return [self._get_item(t) for t in tags]
        else:
            return [self._get_item(_tag)] if self._fs.isdir(_tag.path()) else []

    def _get_item(self, tag: Tag) -> Item:
        """
        Creates a new instance of Item that represents the item with tag `tag`.
        """
        return self._item_type.from_fs(self._fs.opendir(tag.path()))

    def get(self, tag: t.Union[Tag, str]) -> Item:
        """
        store.get("my_bento")
        store.get("my_bento:v1.0.0")
        store.get(Tag("my_bento", "latest"))
        """
        _tag = Tag.from_taglike(tag)
        if _tag.version is None or _tag.version == "latest":
            try:
                _tag.version = self._fs.readtext(_tag.latest_path())
            except fs.errors.ResourceNotFound:
                raise NotFound(
                    f"no {self._item_type.__name__}s with name '{_tag.name}' exist in BentoML store {self._fs}"
                )

        path = _tag.path()
        if self._fs.exists(path):
            return self._get_item(_tag)

        matches = self._fs.glob(f"{path}*/")
        counts = matches.count().directories
        if counts == 0:
            raise NotFound(
                f"{self._item_type.__name__} '{tag}' is not found in BentoML store {self._fs}"
            )
        elif counts == 1:
            match = next(iter(matches))
            return self._get_item(Tag(_tag.name, match.info.name))
        else:
            vers: t.List[str] = []
            for match in matches:
                vers += match.info.name
            raise BentoMLException(
                f"multiple versions matched by {_tag.version}: {vers}"
            )

    @contextmanager
    def register(self, tag: t.Union[str, Tag]):
        _tag = Tag.from_taglike(tag)

        item_path = _tag.path()
        if self._fs.exists(item_path):
            raise BentoMLException(
                f"Item '{_tag}' already exists in the store {self._fs}"
            )
        self._fs.makedirs(item_path)
        try:
            yield self._fs.getsyspath(item_path)
        finally:
            # item generation is most likely successful, link latest path
            if (
                not self._fs.exists(_tag.latest_path())
                or self.get(_tag).creation_time > self.get(_tag.name).creation_time
            ):
                with self._fs.open(_tag.latest_path(), "w") as latest_file:
                    latest_file.write(_tag.version)

    def delete(self, tag: t.Union[str, Tag]) -> None:
        _tag = Tag.from_taglike(tag)

        if not self._fs.exists(_tag.path()):
            raise NotFound(f"{self._item_type.__name__} '{tag}' not found")

        self._fs.removetree(_tag.path())
        if self._fs.isdir(_tag.name):
            versions = self.list(_tag.name)
            if len(versions) == 0:
                # if we've removed all versions, remove the directory
                self._fs.removetree(_tag.name)
            else:
                new_latest = sorted(versions, key=lambda x: x.creation_time)[-1]
                # otherwise, update the latest version
                assert new_latest.tag.version is not None
                self._fs.writetext(_tag.latest_path(), new_latest.tag.version)
