import itertools, string
from datetime import datetime
from random import shuffle

import zxcvbn
from tinydb import TinyDB, Query
from tinydb.middlewares import CachingMiddleware
from tinydb_serialization import Serializer, SerializationMiddleware


class DateTimeSerializer(Serializer):
    OBJ_CLASS = datetime

    def encode(self, obj):
        return obj.strftime("%Y-%m-%dT%H:%M:%S")

    def decode(self, s):
        return datetime.strptime(s, "%Y-%m-%dT%H:%M:%S")


class DomainDoesntExist(ValueError):
    def __init__(self, message, tables):
        self.message = message
        self.tables = tables


class HashDatabase:
    BLANK_NTLMHASH = "31d6cfe0d16ae931b73c59d7e0c089c0"

    def __init__(self, db_name, domain, raise_if_table_doesnt_exist=True, only_enabled=False, only_users=False):
        self.db = None
        self.table = None
        self.only_enabled = (Query().enabled.exists() if only_enabled else Query().ntlmhash.exists()) & ( Query().enabled == True if only_enabled else Query().ntlmhash.exists())
        self.only_users = (Query().username.exists() if only_users else Query().ntlmhash.exists()) & (Query().username.test(lambda v: not v.endswith("$")) if only_users else Query().ntlmhash.exists())

        serialization = SerializationMiddleware()
        serialization.register_serializer(DateTimeSerializer(), "datetime")

        self.db = TinyDB(db_name, storage=CachingMiddleware(serialization))

        tables = list(self.db.tables())
        if raise_if_table_doesnt_exist and domain not in tables:
            raise DomainDoesntExist("Hashes for domain '{}' do not exist in database.".format(domain), tables)

        self.table = self.db.table(domain)

    def __enter__(self):
        return self

    def __exit__(self, type, value, traceback):
        self.db.close()

    @property
    def counts(self):
        total = self.table.count(self.only_enabled & self.only_users)
        local_users = self.table.count((~ Query().historic.exists()) & (Query().username.test(lambda v: "\\" not in v and not v.endswith("$"))) & self.only_users)
        domain_users = self.table.count((~ Query().historic.exists()) & (Query().username.test(lambda v: "\\" in v and not v.endswith("$"))) & self.only_users)
        computers = self.table.count(Query().username.test(lambda v: v.endswith("$")))

        return total, local_users, domain_users, computers

    @property
    def user_counts(self):
        enabled_users = self.table.search((Query().enabled == True) & (Query().username.test(lambda v: not v.endswith("$"))))
        disabled_users = self.table.search((Query().enabled == False) & (Query().username.test(lambda v: not v.endswith("$"))))

        return len(enabled_users), len(disabled_users)

    @property
    def password_stats(self):
        cracked = self.table.count((Query().password.exists()) & (Query().password != "") & self.only_users & self.only_enabled)
        blank = self.table.count(Query().ntlmhash == HashDatabase.BLANK_NTLMHASH)
        historic = self.table.count((Query().historic.exists()) & self.only_enabled & self.only_users)

        return cracked, blank, historic

    @property
    def all_passwords(self):
        results = self.table.search((Query().password.exists()) & (Query().password != "") & self.only_users & self.only_enabled)

        return [(result["password"], zxcvbn.password_strength(result["password"])["score"]) for result in results]

    @property
    def password_composition_stats(self):
        alphanum = string.ascii_letters + string.digits
        only_alpha = self.table.count(Query().password.test(lambda p: p != "" and all(c in alphanum for c in p)))
        with_special = self.table.count(Query().password.test(lambda p: p != "" and any(c not in alphanum for c in p)))
        only_digits = self.table.count(Query().password.test(lambda p: p != "" and all(c in string.digits for c in p)))

        return only_alpha, with_special, only_digits

    def get_historic_passwords(self, limit=10):
        results = sorted(self.table.search((Query().password.exists()) & (Query().password != "") & (Query().historic.exists()) & (Query().username.exists()) & self.only_enabled), key=lambda r: r["username"])
        passwords = ((user, len(list(count))) for user, count in itertools.groupby(results, lambda r: r["username"]))

        return sorted(list((user, self.__get_passwords_for_user(user))
                           for user, count in passwords), key=lambda (user, passwords): len(passwords), reverse=True)[:limit]

    def get_passwords(self, sortby, reverse=True, limit=10):
        results = sorted(self.table.search((Query().password.exists()) & self.only_users & self.only_enabled), key=lambda r: r["password"])
        passwords = ((password, len(list(count))) for password, count in itertools.groupby(results, lambda r: r["password"]))

        return sorted(list(
            (password, count, zxcvbn.password_strength(password)["score"], self.__get_users_with_password(password))
                for password, count in passwords), key=sortby, reverse=reverse)[:limit]

    def get_passwords_where(self, where):
        return self.table.search((Query().password.exists()) & (Query().password.test(where)) & self.only_users & self.only_enabled)

    def update_hash_password(self, hash, password):
        self.table.update({"ntlmhash": hash, "password": password, "updated": datetime.now()}, Query().ntlmhash == hash)

    def insert(self, record):
        record["created"] = datetime.now()

        self.table.insert(record)


    def __get_users_with_password(self, password):
        users = self.table.search(
            (Query().password.exists()) & (Query().username.exists()) & (Query().password == password)
            & self.only_users & self.only_enabled
        )
        shuffle(users)

        return users

    def __get_passwords_for_user(self, user):
        passwords = sorted(self.table.search((Query().password.exists()) & (Query().password != "") & (Query().username.exists()) & (Query().username == user) & self.only_enabled), key=lambda r: r["password"])
        grouped_passwords = ((password, users) for password, users in itertools.groupby(passwords, lambda r: r["password"]))

        return list(grouped_passwords)