"""
Database models for the TestSuites abstraction.
"""

import importlib.metadata
import json
from . import util

import sqlalchemy
import sqlalchemy.ext.declarative
import sqlalchemy.orm
from sqlalchemy import Column, Integer, ForeignKey, String, Binary, Float
from sqlalchemy.orm import relation
from lnt.util import logger

Base = sqlalchemy.ext.declarative.declarative_base()  # type: sqlalchemy.ext.declarative.api.DeclarativeMeta


class SampleType(Base):
    """
    The SampleType table describes an enumeration for the possible types
    clients can configure for different sample fields.
    """
    __tablename__ = 'SampleType'

    id = Column("ID", Integer, primary_key=True)
    name = Column("Name", String(256), unique=True)

    # FIXME: We expect the database to have a limited number of instances of
    # this class, we should just provide static class variables for the various
    # types once bound.

    def __init__(self, name):
        self.name = name

    def __repr__(self):
        return '%s%r' % (self.__class__.__name__, (self.name,))


class StatusKind(Base):
    """
    The StatusKind table describes an enumeration for the possible values
    clients can use for "Status" typed samples. This is designed to match the
    values which are in use by test produces and are defined in the lnt.testing
    module.
    """

    __tablename__ = 'StatusKind'

    id = Column("ID", Integer, primary_key=True, autoincrement=False)
    name = Column("Name", String(256), unique=True)

    def __init__(self, id, name):
        self.id = id
        self.name = name

    def __repr__(self):
        return '%s%r' % (self.__class__.__name__, (self.name,))


_sample_type_to_sql = {
    'Real': Float,
    'Hash': String,
    'Status': Integer,
}


def is_known_sample_type(name):
    return name in _sample_type_to_sql


def make_sample_column(name, type):
    sqltype = _sample_type_to_sql.get(type)
    if sqltype is None:
        raise ValueError("test suite defines unknown sample type %r" % type)
    options = []
    if type == 'Status':
        options.append(ForeignKey(StatusKind.id))
    return Column(name, sqltype, *options)


def make_run_column(name):
    return Column(name, String(256))


def make_machine_column(name):
    return Column(name, String(256))


class _MigrationError(Exception):
    def __init__(self, message):
        full_message = \
            "Cannot automatically migrate database: %s" % message
        super(_MigrationError, self).__init__(full_message)


class TestSuiteJSONSchema(Base):
    """
    Saves the json schema used when creating a testsuite. Only used for suites
    created with a json schema description.
    """
    __tablename__ = 'TestSuiteJSONSchemas'
    testsuite_name = Column("TestSuiteName", String(256), primary_key=True)
    jsonschema = Column("JSONSchema", Binary)

    def __init__(self, testsuite_name, data):
        self.testsuite_name = testsuite_name
        self.jsonschema = json.dumps(data, sort_keys=True).encode('utf-8')


class TestSuite(Base):
    __tablename__ = 'TestSuite'

    id = Column("ID", Integer, primary_key=True)
    name = Column("Name", String(256), unique=True)

    # The name we use to prefix the per-testsuite databases.
    db_key_name = Column("DBKeyName", String(256))

    # The version of the schema used for the per-testsuite databases (encoded
    # as the llvm-lnt package version).
    version = Column("Version", String(16))

    machine_fields = relation('MachineField', backref='test_suite',
                              lazy='immediate')
    order_fields = relation('OrderField', backref='test_suite',
                            lazy='immediate')
    run_fields = relation('RunField', backref='test_suite',
                          lazy='immediate')
    sample_fields = relation('SampleField', backref='test_suite',
                             lazy='immediate')

    def __init__(self, name, db_key_name):
        self.name = name
        self.db_key_name = db_key_name
        (major, minor, *_) = importlib.metadata.version('llvm-lnt').split('.')
        self.version = f"{major}.{minor}"

    def __repr__(self):
        return '%s%r' % (self.__class__.__name__, (self.name, self.db_key_name,
                                                   self.version))

    @staticmethod
    def from_json(data):
        if data.get('format_version') != '2':
            raise ValueError("Expected \"format_version\": \"2\" in schema")
        name = data['name']
        ts = TestSuite(name=data['name'], db_key_name=data['name'])

        machine_fields = []
        for field_desc in data.get('machine_fields', []):
            name = field_desc['name']
            field = MachineField(name)
            machine_fields.append(field)
        ts.machine_fields = machine_fields

        run_fields = []
        order_fields = []
        for field_desc in data.get('run_fields', []):
            name = field_desc['name']
            is_order = field_desc.get('order', False)
            if is_order:
                field = OrderField(name, ordinal=0)
                order_fields.append(field)
            else:
                field = RunField(name)
                run_fields.append(field)
        ts.run_fields = run_fields
        ts.order_fields = order_fields
        assert len(order_fields) > 0

        sample_fields = []
        for index, metric_desc in enumerate(data['metrics']):
            name = metric_desc['name']
            bigger_is_better = metric_desc.get('bigger_is_better', False)
            metric_type_name = metric_desc.get('type', 'Real')
            display_name = metric_desc.get('display_name')
            unit = metric_desc.get('unit')
            unit_abbrev = metric_desc.get('unit_abbrev')
            if not is_known_sample_type(metric_type_name):
                raise ValueError("Unknown metric type '%s'" %
                                 metric_type_name)
            metric_type = SampleType(metric_type_name)
            bigger_is_better_int = 1 if bigger_is_better else 0
            field = SampleField(name, metric_type, index, status_field=None,
                                bigger_is_better=bigger_is_better_int,
                                display_name=display_name, unit=unit,
                                unit_abbrev=unit_abbrev)
            sample_fields.append(field)
        ts.sample_fields = sample_fields
        ts.jsonschema = data
        return ts

    def __json__(self):
        metrics = []
        for sample_field in self.sample_fields:
            metric = {
                'bigger_is_better': (sample_field.bigger_is_better != 0),
                'display_name': sample_field.display_name,
                'name': sample_field.name,
                'type': sample_field.type.name,
                'unit': sample_field.unit,
                'unit_abbrev': sample_field.unit_abbrev,
            }
            metrics.append(metric)
        machine_fields = []
        for machine_field in self.machine_fields:
            field = {
                'name': machine_field.name
            }
            machine_fields.append(field)
        run_fields = []
        for run_field in self.run_fields:
            field = {
                'name': run_field.name
            }
            run_fields.append(field)
        for order_field in self.order_fields:
            field = {
                'name': order_field.name,
                'order': True,
            }
            run_fields.append(field)
        metrics.sort(key=lambda x: x['name'])
        machine_fields.sort(key=lambda x: x['name'])
        run_fields.sort(key=lambda x: x['name'])

        return {
            'format_version': '2',
            'machine_fields': machine_fields,
            'metrics': metrics,
            'name': self.name,
            'run_fields': run_fields,
        }


class FieldMixin(object):
    @property
    def title(self):
        """
        Return a title for the given field by replacing all _ with
        spaces and that has every word capitalized.
        """
        return self.name.replace("_", " ").title()


class MachineField(FieldMixin, Base):
    __tablename__ = 'TestSuiteMachineFields'

    id = Column("ID", Integer, primary_key=True)
    test_suite_id = Column("TestSuiteID", Integer, ForeignKey('TestSuite.ID'),
                           index=True)
    name = Column("Name", String(256))

    def __init__(self, name):
        self.name = name

        # Column instance for fields which have been bound (non-DB
        # parameter). This is provided for convenience in querying.
        self.column = None

    def __repr__(self):
        return '%s%r' % (self.__class__.__name__, (self.name, ))

    def __copy__(self):
        return MachineField(self.name)


class OrderField(FieldMixin, Base):
    __tablename__ = 'TestSuiteOrderFields'

    id = Column("ID", Integer, primary_key=True)
    test_suite_id = Column("TestSuiteID", Integer, ForeignKey('TestSuite.ID'),
                           index=True)
    name = Column("Name", String(256))

    # The ordinal index this field should be used at for creating a
    # lexicographic ordering amongst runs.
    ordinal = Column("Ordinal", Integer)

    def __init__(self, name, ordinal):
        assert isinstance(ordinal, int) and ordinal >= 0

        self.name = name
        self.ordinal = ordinal

        # Column instance for fields which have been bound (non-DB
        # parameter). This is provided for convenience in querying.
        self.column = None

    def __repr__(self):
        return '%s%r' % (self.__class__.__name__, (self.name, self.ordinal))

    def __copy__(self):
        return OrderField(self.name, self.ordinal)


class RunField(FieldMixin, Base):
    __tablename__ = 'TestSuiteRunFields'

    id = Column("ID", Integer, primary_key=True)
    test_suite_id = Column("TestSuiteID", Integer, ForeignKey('TestSuite.ID'),
                           index=True)
    name = Column("Name", String(256))

    def __init__(self, name):
        self.name = name

        # Column instance for fields which have been bound (non-DB
        # parameter). This is provided for convenience in querying.
        self.column = None

    def __repr__(self):
        return '%s%r' % (self.__class__.__name__, (self.name, ))

    def __copy__(self):
        return RunField(self.name)


class SampleField(FieldMixin, Base):
    __tablename__ = 'TestSuiteSampleFields'

    id = Column("ID", Integer, primary_key=True)
    test_suite_id = Column("TestSuiteID", Integer, ForeignKey('TestSuite.ID'),
                           index=True)
    name = Column("Name", String(256))

    # The type of sample this is.
    type_id = Column("Type", Integer, ForeignKey('SampleType.ID'))
    type = relation(SampleType, lazy='immediate')

    # The status field is used to create a relation to the sample field that
    # reports the status (pass/fail/etc.) code related to this value. This
    # association is used by UI code to present the two status fields together.
    status_field_id = Column("status_field", Integer, ForeignKey(
            'TestSuiteSampleFields.ID'))
    status_field = relation('SampleField', remote_side=id, lazy='immediate')

    # Most real type samples assume lower values are better than higher values.
    # This assumption can be inverted by setting this column to nonzero.
    bigger_is_better = Column("bigger_is_better", Integer)

    def __init__(self, name, type, schema_index, status_field=None, bigger_is_better=0,
                 display_name=None, unit=None, unit_abbrev=None):
        self.name = name
        self.type = type
        self.status_field = status_field
        self.bigger_is_better = bigger_is_better
        self.display_name = name if display_name is None else display_name
        self.unit = unit
        self.unit_abbrev = unit_abbrev
        self.schema_index = schema_index

        # Column instance for fields which have been bound (non-DB
        # parameter). This is provided for convenience in querying.
        self.column = None

    @sqlalchemy.orm.reconstructor
    def init_on_load(self):
        self.display_name = self.name
        self.unit = None
        self.unit_abbrev = None
        self.schema_index = -1

    def __repr__(self):
        return '%s%r' % (self.__class__.__name__, (self.name, self.type, ))

    def __copy__(self):
        return SampleField(self.name, self.type, self.schema_index, self.status_field,
                           self.bigger_is_better, self.display_name, self.unit,
                           self.unit_abbrev)

    def copy_info(self, other):
        self.display_name = other.display_name
        self.unit = other.unit
        self.unit_abbrev = other.unit_abbrev
        self.schema_index = other.schema_index


def _upgrade_to(connectable, tsschema, new_schema, dry_run=False):
    new = json.loads(new_schema.jsonschema)
    old = json.loads(tsschema.jsonschema)
    ts_name = new['name']
    if old['name'] != ts_name:
        raise _MigrationError("Schema names differ?!?")

    old_metrics = {}
    for metric_desc in old.get('metrics', []):
        old_metrics[metric_desc['name']] = metric_desc

    for metric_desc in new.get('metrics', []):
        name = metric_desc['name']
        old_metric = old_metrics.pop(name, None)
        type = metric_desc['type']
        if old_metric is not None:
            if old_metric['type'] != type:
                raise _MigrationError("Type mismatch in metric '%s'" %
                                      name)
        elif not dry_run:
            # Add missing columns
            column = make_sample_column(name, type)
            util.add_column(connectable, '%s_Sample' % ts_name, column)

    if len(old_metrics) != 0:
        raise _MigrationError("Metrics removed: %s" %
                              ", ".join(old_metrics.keys()))

    old_run_fields = {}
    old_order_fields = {}
    for field_desc in old.get('run_fields', []):
        if field_desc.get('order', False):
            old_order_fields[field_desc['name']] = field_desc
            continue
        old_run_fields[field_desc['name']] = field_desc

    for field_desc in new.get('run_fields', []):
        name = field_desc['name']
        if field_desc.get('order', False):
            old_order_field = old_order_fields.pop(name, None)
            if old_order_field is None:
                raise _MigrationError("Cannot add order field '%s'" %
                                      name)
            continue

        old_field = old_run_fields.pop(name, None)
        # Add missing columns
        if old_field is None and not dry_run:
            column = make_run_column(name)
            util.add_column(connectable, '%s_Run' % ts_name, column)

    if len(old_run_fields) > 0:
        raise _MigrationError("Run fields removed: %s" %
                              ", ".join(old_run_fields.keys()))
    if len(old_order_fields) > 0:
        raise _MigrationError("Order fields removed: %s" %
                              ", ".join(old_order_fields.keys()))

    old_machine_fields = {}
    for field_desc in old.get('machine_fields', []):
        name = field_desc['name']
        old_machine_fields[name] = field_desc

    for field_desc in new.get('machine_fields', []):
        name = field_desc['name']
        old_field = old_machine_fields.pop(name, None)
        # Add missing columns
        if old_field is None and not dry_run:
            column = make_machine_column(name)
            util.add_column(connectable, '%s_Machine' % ts_name, column)

    if len(old_machine_fields) > 0:
        raise _MigrationError("Machine fields removed: %s" %
                              ", ".join(old_machine_fields.keys()))
    # The rest should just be metadata that we can upgrade
    return True


def check_testsuite_schema_changes(session, testsuite):
    """Check whether the given testsuite that was loaded from a json/yaml
    file changed compared to the previous schema stored in the database.
    The database is automatically migrated for trivial changes or we throw
    and exception if automatic migration is not possible."""
    name = testsuite.name
    schema = TestSuiteJSONSchema(name, testsuite.jsonschema)
    prev_schema = session.query(TestSuiteJSONSchema) \
        .filter(TestSuiteJSONSchema.testsuite_name == name).first()
    if prev_schema is not None:
        if prev_schema.jsonschema != schema.jsonschema:
            logger.info("Previous Schema:")
            logger.info(json.dumps(json.loads(prev_schema.jsonschema),
                                   indent=2))
            # First do a dry run to check whether the upgrade will succeed.
            _upgrade_to(session, prev_schema, schema, dry_run=True)
            # Not perform the actual upgrade. This shouldn't fail as the dry
            # run already worked fine.
            _upgrade_to(session, prev_schema, schema)

            prev_schema.jsonschema = schema.jsonschema
            session.add(prev_schema)
            session.commit()
    else:
        session.add(schema)
        session.commit()


def _sync_fields(session, existing_fields, new_fields):
    for new_field in new_fields:
        existing = None
        for existing_field in existing_fields:
            if existing_field.name == new_field.name:
                existing = existing_field
                break
        if existing is None:
            existing_fields.append(new_field.__copy__())
        elif hasattr(existing, 'copy_info'):
            existing.copy_info(new_field)


def sync_testsuite_with_metatables(session, testsuite):
    """Update the metatables according a TestSuite object that was loaded
    from a yaml description."""
    name = testsuite.name

    # Replace metric_type fields with objects queried from database.
    sampletypes = session.query(SampleType).all()
    sampletypes = dict([(st.name, st) for st in sampletypes])
    for sample_field in testsuite.sample_fields:
        metric_type_name = sample_field.type.name
        sample_field.type = sampletypes[metric_type_name]

    # Update or create the TestSuite entry.
    existing_ts = session.query(TestSuite) \
        .filter(TestSuite.name == name).first()
    if existing_ts is None:
        session.add(testsuite)
    else:
        # Add missing fields (Note that we only need to check for newly created
        # fields, removed ones should be catched by check_schema_changes()).
        _sync_fields(session, existing_ts.machine_fields,
                     testsuite.machine_fields)
        _sync_fields(session, existing_ts.run_fields, testsuite.run_fields)
        _sync_fields(session, existing_ts.order_fields, testsuite.order_fields)
        _sync_fields(session, existing_ts.sample_fields,
                     testsuite.sample_fields)
        testsuite = existing_ts
    return testsuite
