"""
Implementations for the history of a `Buffer`.

NOTE: There is no `DynamicHistory`:
      This doesn't work well, because the `Buffer` needs to be able to attach
      an event handler to the event when a history entry is loaded. This
      loading can be done asynchronously and making the history swappable would
      probably break this.
"""
import datetime
import os
from abc import ABCMeta, abstractmethod
from threading import Thread
from typing import Callable, Iterable, List, Optional

__all__ = [
    "History",
    "ThreadedHistory",
    "DummyHistory",
    "FileHistory",
    "InMemoryHistory",
]


class History(metaclass=ABCMeta):
    """
    Base ``History`` class.

    This also includes abstract methods for loading/storing history.
    """

    def __init__(self) -> None:
        # In memory storage for strings.
        self._loaded = False
        self._loaded_strings: List[str] = []

    #
    # Methods expected by `Buffer`.
    #

    def load(self, item_loaded_callback: Callable[[str], None]) -> None:
        """
        Load the history and call the callback for every entry in the history.

        XXX: The callback can be called from another thread, which happens in
             case of `ThreadedHistory`.

             We can't assume that an asyncio event loop is running, and
             schedule the insertion into the `Buffer` using the event loop.

             The reason is that the creation of the :class:`.History` object as
             well as the start of the loading happens *before*
             `Application.run()` is called, and it can continue even after
             `Application.run()` terminates. (Which is useful to have a
             complete history during the next prompt.)

             Calling `get_event_loop()` right here is also not guaranteed to
             return the same event loop which is used in `Application.run`,
             because a new event loop can be created during the `run`. This is
             useful in Python REPLs, where we want to use one event loop for
             the prompt, and have another one active during the `eval` of the
             commands. (Otherwise, the user can schedule a while/true loop and
             freeze the UI.)
        """
        if self._loaded:
            for item in self._loaded_strings[::-1]:
                item_loaded_callback(item)
            return

        try:
            for item in self.load_history_strings():
                self._loaded_strings.insert(0, item)
                item_loaded_callback(item)
        finally:
            self._loaded = True

    def get_strings(self) -> List[str]:
        """
        Get the strings from the history that are loaded so far.
        """
        return self._loaded_strings

    def append_string(self, string: str) -> None:
        " Add string to the history. "
        self._loaded_strings.append(string)
        self.store_string(string)

    #
    # Implementation for specific backends.
    #

    @abstractmethod
    def load_history_strings(self) -> Iterable[str]:
        """
        This should be a generator that yields `str` instances.

        It should yield the most recent items first, because they are the most
        important. (The history can already be used, even when it's only
        partially loaded.)
        """
        while False:
            yield

    @abstractmethod
    def store_string(self, string: str) -> None:
        """
        Store the string in persistent storage.
        """


class ThreadedHistory(History):
    """
    Wrapper that runs the `load_history_strings` generator in a thread.

    Use this to increase the start-up time of prompt_toolkit applications.
    History entries are available as soon as they are loaded. We don't have to
    wait for everything to be loaded.
    """

    def __init__(self, history: History) -> None:
        self.history = history
        self._load_thread: Optional[Thread] = None
        self._item_loaded_callbacks: List[Callable[[str], None]] = []
        super().__init__()

    def load(self, item_loaded_callback: Callable[[str], None]) -> None:
        self._item_loaded_callbacks.append(item_loaded_callback)

        # Start the load thread, if we don't have a thread yet.
        if not self._load_thread:

            def call_all_callbacks(item: str) -> None:
                for cb in self._item_loaded_callbacks:
                    cb(item)

            self._load_thread = Thread(
                target=self.history.load, args=(call_all_callbacks,)
            )
            self._load_thread.daemon = True
            self._load_thread.start()

    def get_strings(self) -> List[str]:
        return self.history.get_strings()

    def append_string(self, string: str) -> None:
        self.history.append_string(string)

    # All of the following are proxied to `self.history`.

    def load_history_strings(self) -> Iterable[str]:
        return self.history.load_history_strings()

    def store_string(self, string: str) -> None:
        self.history.store_string(string)

    def __repr__(self) -> str:
        return "ThreadedHistory(%r)" % (self.history,)


class InMemoryHistory(History):
    """
    :class:`.History` class that keeps a list of all strings in memory.
    """

    def load_history_strings(self) -> Iterable[str]:
        return []

    def store_string(self, string: str) -> None:
        pass


class DummyHistory(History):
    """
    :class:`.History` object that doesn't remember anything.
    """

    def load_history_strings(self) -> Iterable[str]:
        return []

    def store_string(self, string: str) -> None:
        pass

    def append_string(self, string: str) -> None:
        # Don't remember this.
        pass


class FileHistory(History):
    """
    :class:`.History` class that stores all strings in a file.
    """

    def __init__(self, filename: str) -> None:
        self.filename = filename
        super(FileHistory, self).__init__()

    def load_history_strings(self) -> Iterable[str]:
        strings: List[str] = []
        lines: List[str] = []

        def add() -> None:
            if lines:
                # Join and drop trailing newline.
                string = "".join(lines)[:-1]

                strings.append(string)

        if os.path.exists(self.filename):
            with open(self.filename, "rb") as f:
                for line_bytes in f:
                    line = line_bytes.decode("utf-8", errors="replace")

                    if line.startswith("+"):
                        lines.append(line[1:])
                    else:
                        add()
                        lines = []

                add()

        # Reverse the order, because newest items have to go first.
        return reversed(strings)

    def store_string(self, string: str) -> None:
        # Save to file.
        with open(self.filename, "ab") as f:

            def write(t: str) -> None:
                f.write(t.encode("utf-8"))

            write("\n# %s\n" % datetime.datetime.now())
            for line in string.split("\n"):
                write("+%s\n" % line)
