import hashlib
from .. import db
from . import (TestDatum, Datum, Disposition, ItemDoesNotExistException,
               ItemExistsException)
from .base import BaseModel


class Test(BaseModel):
    __tablename__ = 'tests'

    # fields
    user_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
    name = db.Column(db.String(length=160), nullable=False)

    # the purpose of hashed_data is to store a hash of a string. This string is
    # just a sorted, comma-separated list of all the data belonging to this
    # test object, along with their label and disposition. The reason we want
    # to store this information is because we need to check uniqueness of
    # combinations of test data, but this is obscenely difficult to do with
    # a SQL query given the use of the intermediary TestDatum table. It's
    # easier to just keep a hash of the ids here so we can calculate the
    # similar hash for any new tests and check that it doesn't match the hash
    # of an existing test. See also the similar behavior in Datum
    hashed_data = db.Column(db.String(128), nullable=False)

    private_fields = []
    # data is added manually in to_obj
    public_fields = ['name', 'user_id', 'created_at']

    # relationships
    author = db.relationship('User', back_populates='tests', foreign_keys=[user_id])
    data = db.relationship('TestDatum', back_populates='test', uselist=True, foreign_keys=lambda: TestDatum.test_id)

    def __repr__(self):
        data_repr = list(map(lambda td: f'{td.label}: {td.datum.__repr__()}', self.data))
        return (f"<Test #{self.id} '{self.name}' ({self.author.username})"
                f"\n   {data_repr}\n>")

    def to_obj(self, *args, **kwargs):
        obj = super().to_obj(*args, **kwargs)
        obj['data'] = {}
        for td in self.data:
            obj['data'][td.label] = td.datum.to_obj(*args, **kwargs)
            obj['data'][td.label]['disposition'] = td.disposition.value
        return obj

    def add_datum(self, label, disposition, datum):
        # TODO see if we want piecemeal addition
        pass

    def remove_datum(self, datum):
        # TODO see if we want piecemeal deletion
        pass

    def update(self, *args, **kwargs):
        # TODO share code with create to ensure uniqueness etc
        pass

    @staticmethod
    def parse_test_data_obj(data_obj):
        """
        Take the data object that comes in as JSON and turn it into a more
        useful object that actually references datum objects themselves
        """
        def to_test_datum(data_obj):
            id = data_obj['datum_id']
            datum = Datum.query.filter_by(id=id).first()
            if datum is None:
                msg = (f'You referred to datum with id "{id}" but it '
                       f'does not exist')
                raise ItemDoesNotExistException(msg, id)
            return {
                'label': data_obj['label'],
                'disposition': Disposition(data_obj['disposition']),
                'datum': datum
            }

        return list(map(to_test_datum, data_obj))

    @staticmethod
    def get_data_hash(data):
        """
        This method creates a (hopefully unique) hash based on the combination
        of (a) label, (b) disposition, and (c) test datum id, for each bit
        of test data. It expects as input something that has gone through
        Test.parse_test_data_obj
        """
        data.sort(key=lambda d: d['label'])

        def get_data_str(d):
            return f"{d['label']}:{d['disposition'].value}:{d['datum'].id}"

        data_str = ",".join(map(get_data_str, data))
        return hashlib.sha1(data_str.encode('utf-8')).hexdigest()

    @staticmethod
    def ensure_uniqueness(data):
        hashed_data = Test.get_data_hash(data)
        existing_test = Test.query.filter_by(hashed_data=hashed_data).first()
        if existing_test is not None:
            msg = (f"The combination of test data, labels, and dispositions "
                   f"used in this test already exists for test with id "
                   f"{existing_test.id}")
            raise ItemExistsException(msg, existing_test.id)

    @staticmethod
    def create(*args, **kwargs):
        if len(kwargs.get('data', [])) == 0:
            raise Exception('Cannot create test without test data')

        data = kwargs['data']
        labels = list(map(lambda d: d['label'], data))
        if len(labels) != len(set(labels)):
            raise Exception('You have a duplicate test data label. You can only '
                            'use each label once per test')

        data = Test.parse_test_data_obj(data)

        Test.ensure_uniqueness(data)

        del kwargs['data']
        kwargs['hashed_data'] = Test.get_data_hash(data)
        test = Test(**kwargs)
        db.session.add(test)
        db.session.flush()
        for td_data in data:
            td = TestDatum(test_id=test.id, label=td_data['label'],
                           disposition=td_data['disposition'], datum=td_data['datum'])
            db.session.add(td)
        db.session.commit()
        return test
