import time

import dataset
import os
from passlib.hash import sha256_crypt
import uuid

from .models import Action, NodeInfo, ActionType, Song, Revision

_SQLITE_PREFIX = "sqlite:///"
_SQLITE_MEM = "sqlite:///:memory:"
HUB_DB_FILENAME = ".klon-hub.db"
NODE_DB_FILENAME = ".klon-node.db"


class Database:
    def __init__(self, db_file_name, db_path=None):
        if db_path != _SQLITE_MEM:
            self.path = db_path if db_path is not None else os.getcwd()
            self._file = os.path.join(self.path, db_file_name)
            self._db = dataset.connect(_SQLITE_PREFIX + self._file)
        else:
            self.path = "/tmp/"
            self._db = dataset.connect(db_path)

    @property
    def songs(self):
        """
        Returns all songs in the database. These don't always coincide with the actual songs on disk.

        :return: All songs in the database.
        :rtype: list of Song
        """
        return list(
            [Song(**dict((k, v) for k, v in data.items() if k != "id"))
             for data in self._db["songs"].all()]
        )

    def get_song_at_path(self, path):
        row = self._db["songs"].find_one(path=path)
        if not row:
            return None
        del row["id"]
        return Song(**row)

    def get_equivalent_song(self, artist, record, title):
        if artist is None or record is None or title is None:
            return None

        row = self._db["songs"].find_one(artist=artist, record=record, title=title)
        if not row:
            return None
        del row["id"]
        return Song(**row)

    def get_song_paths(self):
        if not self._db["songs"] or "path" not in self._db["songs"].columns:
            return []
        return [row["path"] for row in self._db.query("SELECT path FROM songs")]

    def _update_songs(self, actions):
        for action in actions:
            row = dict(action.__dict__)
            del row["action_type"]
            if "prev_path" in row:
                del row["prev_path"]
            if action.action_type == ActionType.add:
                self._db["songs"].insert(row)
            elif action.action_type == ActionType.edit or action.action_type == ActionType.replace:
                self._db["songs"].update(row, ["path"])
            elif action.action_type == ActionType.remove:
                self._db["songs"].delete(path=action.path)

    def transaction(self):
        """
        Creates and returns a new database transaction.

        :return: a running transaction
        :rtype: _DatabaseTransaction
        """
        return DatabaseTransaction(self._db)

    def clear(self):
        for table in self._db.tables:
            if table != "meta":
                self._db[table].drop()


class DatabaseTransaction:
    """
    Objects of this class represent a running database transaction.

    usage: (don't use _DatabaseTransaction's constructor directly)
    >>> with db.transaction() as transaction:
    """
    def __init__(self, db):
        self._db = db

    def __enter__(self):
        self._db.begin()
        return self

    def __exit__(self, exc_type, exc_val, exc_tb):
        if not exc_type:
            self.commit()
        else:
            self.rollback()

    def commit(self):
        """
        Make all statements executed since the transaction was begun permanent.
        """
        self._db.commit()

    def rollback(self):
        """
        Discard all statements executed since the transaction was begun.
        """
        self._db.rollback()


class HubDatabase(Database):
    """
    Manage and query information of a hub.
    """

    @staticmethod
    def create(access_pw, db_path=None):
        """
        Creates a new hub database.

        :param access_pw: The connect password will need to be provided by any node that is trying to connect to this
                           hub.
        :type access_pw: str
        :param db_path: the hub's path. This is the path of a music collection. The hub's database file will be
                        created here. a value of None will translate to the current working directory.
        :type db_path: str
        :return: the created HubDatabase instance.
        :rtype: HubDatabase
        """
        db = HubDatabase(db_path)
        db._db["meta"].insert(dict(
            access_pw=sha256_crypt.encrypt(access_pw),
            hub_id=uuid.uuid4().hex
        ))
        return db

    @staticmethod
    def _create_in_memory(access_pw):
        return HubDatabase.create(access_pw, _SQLITE_MEM)

    @staticmethod
    def open(db_path=None):
        """
        Connect to a hub database.

        :param db_path: the path that contains the database file. a value of None will translate to the current working
                        directory.
        :type db_path: str
        :return: the database at that path or None if there is no database at that path
        :rtype: HubDatabase
        """
        if not os.path.exists(os.path.join(db_path if db_path is not None else os.getcwd(), HUB_DB_FILENAME)):
            return None
        return HubDatabase(db_path)

    @staticmethod
    def exists_in(path):
        """
        Tests whether there is a hub database inside the given path.

        :param path: the directory to test against
        :return: True, if a hub database exists inside the given path.
        :rtype: bool
        """
        return os.path.exists(os.path.join(path, HUB_DB_FILENAME))

    def __init__(self, db_path=None):
        """
        Don't use the constructor, use HubDatabase.create() or HubDatabase.open() instead.
        """
        super().__init__(db_path=db_path, db_file_name=HUB_DB_FILENAME)

    @property
    def nodes(self):
        """
        Returns all nodes connected to this hub.

        :return: all nodes connected to this hub
        :rtype: list of NodeInfo
        """
        nodes = []
        for node_row in self._db["nodes"].all():
            del node_row["id"]
            del node_row["node_id"]
            node = NodeInfo(**node_row)
            nodes.append(node)
        return nodes

    @property
    def name(self):
        """
        Like nodes, hubs have ids, but they are all simply "hub".

        :return: the string "hub"
        :rtype: str
        """
        return "hub"

    @property
    def id(self):
        """
        Returns this hub's generated, unique id.

        :return: this hub's generated, unique id
        :rtype: str
        """
        return self._db["meta"].find_one()["hub_id"]

    @property
    def revision_nb(self):
        """
        Yields the database's revision number.

        :return: this database's revision
        :rtype: int
        """
        return max([entry["revision_nb"] for entry in self._db["revisions"].distinct("revision_nb")] or [0])

    def add_node(self, node_name):
        """
        Adds a node that has connected to this hub.

        :param node_name: the name that should be given to the node
        :type node_name: str
        :return: the node's generated, unique id
        :rtype: str
        """
        ts = int(time.time())
        node_id = uuid.uuid4().hex
        self._db["nodes"].insert(dict(
            node_name=node_name,
            node_id=node_id,
            connection_time=ts,
            last_activity=ts,
            revision_nb=0
        ))
        return node_id

    def rename_node(self, node_id, node_name):
        ts = int(time.time())
        return self._db["nodes"].update(dict(
            node_name=node_name,
            node_id=node_id,
            last_activity=ts
        ), ["node_id"]) == 1

    def update_last_activity(self, node_id):
        """
        Update the last activity timestamp of a node.

        :param node_id: the node's id
        :type node_id: str
        """
        self._db["nodes"].update(dict(
            node_id=node_id,
            last_activity=int(time.time())
        ), ["node_id"])

    def update_node_revision(self, node_id):
        """
        Update a node's revision. The timestamp of its last activity will be updated as well.

        :param node_id: the node's id
        :type node_id: str
        """
        self._db["nodes"].update(dict(
            node_id=node_id,
            revision_nb=self.revision_nb,
            last_activity=int(time.time())
        ), ["node_id"])

    def history(self, from_revision=None, to_revision=None):
        """
        Returns all revisions in the given interval. Call without parameters to get the complete history.

        :param from_revision: exclusive lower bound: for value x, results are at least revision x + 1.
                              a value of None will translate to 0
        :param to_revision: inclusive upper bound: for value x, results are at most revision x.
                            a value of None will translate to the current revision
        :type from_revision: int
        :type to_revision: int
        :return: a list of revisions
        :rtype: list of Revision
        """
        if "revisions" not in self._db or "revision_nb" not in self._db["revisions"].columns:
            return []

        revisions_query = """
        SELECT * FROM revisions
        WHERE revision_nb > {} and revision_nb <= {}
        ORDER BY revision_nb ASC
        """.format(
            0 if from_revision is None else int(from_revision),
            self.revision_nb if to_revision is None else int(to_revision)
        )

        revisions = []
        revision_rows = self._db.query(revisions_query)
        for revision_data in revision_rows:
            actions_query = """
            SELECT * FROM actions
            WHERE revision_nb == {}
            ORDER BY path ASC
            """.format(revision_data["revision_nb"])

            del revision_data["id"]
            revision_data["number"] = revision_data.pop("revision_nb")
            revision_data["actions"] = [Action.import_(**row) for row in self._db.query(actions_query)]
            revisions.append(Revision(**revision_data))
        return revisions

    def push_history(self, actions, node_id=None, new_revision=True):
        """
        Creates a new revision in the database. That revision is made up of a list of actions.
        Song entries might be added, updated or removed based on the actions.

        :param actions: the list of actions that will make up the resulting revision
        :type actions: list of Action
        :param node_id: the pushing node's id or None if the hub itself is pushing the changes
        :param node_id: str
        :return:
        """
        with self.transaction():
            if new_revision:
                next_revision = self.revision_nb + 1
                self._db["revisions"].insert(dict(
                    revision_nb=int(next_revision),
                    node_id=node_id,
                    timestamp=int(time.time())
                ))
            else:
                next_revision = self.revision_nb

            action_rows = [action.export(next_revision) for action in actions]
            self._db["actions"].insert_many(action_rows)
            self._update_songs(actions)
            if node_id:
                self.update_node_revision(node_id)

    def is_connected(self, node_name=None, node_id=None):
        query = {}
        if node_name is None and node_id is None:
            return False
        if node_name is not None:
            query["node_name"] = node_name
        if node_id is not None:
            query["node_id"] = node_id
        return self._db["nodes"].find_one(**query) is not None

    def is_up_to_date(self, node_id):
        node = self._db["nodes"].find_one(node_id=node_id)
        return node is not None and node["revision_nb"] == self.revision_nb

    def auth_access(self, access_pw):
        access_pw_hash = self._db["meta"].find_one()["access_pw"]
        return sha256_crypt.verify(access_pw, access_pw_hash)


class NodeDatabase(Database):
    """
    Manage and query information of a node.
    """

    @staticmethod
    def create(node_name, node_id, hub_url, hub_id, db_path):
        """
        Create a new node database.

        :param node_name: the node's name.
        :type node_name: str
        :param node_id: the node's unique id. This should be a unique string, generated by the hub.
        :type node_id: str
        :param hub_url: the url of the hub this node is connected to.
        :type hub_url: str
        :param hub_id: the id of the hub this node is connected to.
        :type hub_id: str
        :param db_path: the node's path. This is the path of a music collection. The node's database file will be
                        created here.
        :type db_path: str
        :rtype: NodeDatabase
        :return: the created NodeDatabase instance.
        :raises ValueError: thrown if node_id is empty
        """
        db = NodeDatabase(db_path)
        db._db["meta"].insert(dict(
            node_name=node_name,
            node_id=node_id,
            hub_url=hub_url,
            hub_id=hub_id,
            revision_nb=0
        ))
        return db

    @staticmethod
    def _create_in_memory(node_name, node_id, hub_url, hub_id):
        return NodeDatabase.create(node_name=node_name, hub_url=hub_url, hub_id=hub_id,
                                   db_path=_SQLITE_MEM, node_id=node_id)

    @staticmethod
    def open(db_path=None):
        """
        Connect to a node database.

        :param db_path: the path that contains the database file. a value of None will translate to the current working
                        directory.
        :type db_path: str
        :return: the database at that path or None if there is no database at that path
        :rtype: NodeDatabase
        """
        if not os.path.exists(os.path.join(db_path if db_path is not None else os.getcwd(), NODE_DB_FILENAME)):
            return None
        return NodeDatabase(db_path)

    @staticmethod
    def exists_in(path):
        """
        Tests whether there is a node database inside the given path.

        :param path: the directory to test against
        :return: True, if a node database exists inside the given path.
        :rtype: bool
        """
        return os.path.exists(os.path.join(path, NODE_DB_FILENAME))

    def __init__(self, db_path=None):
        """
        Don't use the constructor, use NodeDatabase.create() or NodeDatabase.open() instead.
        """
        super().__init__(db_path=db_path, db_file_name=NODE_DB_FILENAME)

    @property
    def name(self):
        """
        Returns this node's id.

        :return: this node's id
        :rtype: str
        """
        return self._db["meta"].find_one()["node_name"]

    @property
    def id(self):
        """
        Returns this node's generated, unique id.

        :return: this node's generated, unique id
        :rtype: str
        """
        return self._db["meta"].find_one()["node_id"]

    @property
    def hub_url(self):
        """
        Returns the url of the hub this node is connected to.

        :return: the hub's url
        :rtype: str
        """
        return self._db["meta"].find_one()["hub_url"]

    @property
    def hub_id(self):
        """
        Returns the id of the hub this node is connected to.

        :return: the hub's id
        :rtype: str
        """
        return self._db["meta"].find_one()["hub_id"]

    @property
    def revision_nb(self):
        """
        Yields the database's revision number.

        :return: this database's revision
        :rtype: int
        """
        return self._db["meta"].find_one()["revision_nb"]

    def reconnect(self, name, hub_url, hub_id):
        curr_hub_id = self._db["meta"].find_one()["hub_id"]
        self._db["meta"].update(dict(
            id=1,
            node_name=name,
            hub_url=hub_url,
            hub_id=hub_id,
            revision_nb=self.revision_nb if curr_hub_id == hub_id else 0
        ), ["id"])

    def update_songs(self, actions, revision_nb):
        self._update_songs(actions)
        self._db["meta"].update(dict(
            id=1,
            revision_nb=revision_nb
        ), ["id"])

    def clear(self):
        super().clear()
        self._db["meta"].update(dict(
            id=1,
            revision_nb=0
        ), ["id"])

    def set_local_changes(self, actions):
        action_rows = [action.export() for action in actions]
        self._db["local_actions"].insert_many(action_rows)

    def pop_local_changes(self, increase_revision=False):
        if "local_actions" not in self._db:
            return []

        actions = [Action.import_(**row) for row in self._db.query("SELECT * FROM local_actions ORDER BY id ASC")]
        with self.transaction():
            self._db.query("DELETE FROM local_actions")
            if increase_revision:
                self._db["meta"].update(dict(
                    id=1,
                    revision_nb=self.revision_nb + 1
                ), ["id"])

        return actions
