# Copyright (C) 2019-2021 Apple Inc. All rights reserved.
#
# Redistribution and use in source and binary forms, with or without
# modification, are permitted provided that the following conditions
# are met:
# 1.  Redistributions of source code must retain the above copyright
#     notice, this list of conditions and the following disclaimer.
# 2.  Redistributions in binary form must reproduce the above copyright
#     notice, this list of conditions and the following disclaimer in the
#     documentation and/or other materials provided with the distribution.
#
# THIS SOFTWARE IS PROVIDED BY APPLE INC. AND ITS CONTRIBUTORS "AS IS" AND
# ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
# WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
# DISCLAIMED. IN NO EVENT SHALL APPLE INC. OR ITS CONTRIBUTORS BE LIABLE FOR
# ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
# DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
# SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
# CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
# OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.

import calendar
import json
import time

from cassandra.cqlengine import columns
from cassandra.cqlengine.models import Model
from collections import OrderedDict
from collections.abc import Iterable
from datetime import datetime
from resultsdbpy.controller.configuration import Configuration
from resultsdbpy.model.commit_context import CommitContext


class ClusteredByConfiguration(Model):
    platform = columns.Text(partition_key=True, required=True)
    version = columns.Integer(partition_key=True, required=True)
    is_simulator = columns.Boolean(partition_key=True, required=True)
    architecture = columns.Text(partition_key=True, required=True)
    attributes = columns.Text(partition_key=True, required=True)

    def to_configuration(self):
        return Configuration(
            platform=self.platform,
            version=self.version,
            is_simulator=self.is_simulator,
            architecture=self.architecture,
            sdk=getattr(self, 'sdk', None) or None,
            **json.loads(self.attributes)
        )


class ConfigurationContext(object):

    class ConfigurationModel(object):

        def to_configuration(self):
            return Configuration(
                platform=self.platform,
                version=self.version, version_name=self.version_name or None,
                sdk=getattr(self, 'sdk', None) or None,
                is_simulator=self.is_simulator,
                architecture=self.architecture, model=self.model or None,
                style=self.style or None, flavor=self.flavor or None,
            )

    class ByPlatform(Model, ConfigurationModel):
        __table_name__ = 'configs_by_platform_with_branch'
        platform = columns.Text(partition_key=True, required=True)
        style = columns.Text(primary_key=True, required=False)
        flavor = columns.Text(primary_key=True, required=False)
        architecture = columns.Text(primary_key=True, required=True)
        model = columns.Text(primary_key=True, required=False)
        is_simulator = columns.Boolean(primary_key=True, required=True)
        version = columns.Integer(primary_key=True, required=True)
        branch = columns.Text(primary_key=True, required=True)
        version_name = columns.Text(primary_key=True, required=False)
        last_run = columns.DateTime(required=True)

    class ByPlatformAndVersion(Model, ConfigurationModel):
        __table_name__ = 'configs_by_platform_and_version_with_branch'
        platform = columns.Text(partition_key=True, required=True)
        version = columns.Integer(partition_key=True, required=True)
        style = columns.Text(primary_key=True, required=False)
        flavor = columns.Text(primary_key=True, required=False)
        architecture = columns.Text(primary_key=True, required=True)
        model = columns.Text(primary_key=True, required=False)
        is_simulator = columns.Boolean(primary_key=True, required=True)
        branch = columns.Text(primary_key=True, required=True)
        version_name = columns.Text(primary_key=True, required=False)
        last_run = columns.DateTime(required=True)

    class ByArchitecture(Model, ConfigurationModel):
        __table_name__ = 'configs_by_architecture_with_branch'
        architecture = columns.Text(partition_key=True, required=True)
        style = columns.Text(primary_key=True, required=False)
        platform = columns.Text(primary_key=True, required=True)
        version = columns.Integer(primary_key=True, required=True)
        model = columns.Text(primary_key=True, required=False)
        flavor = columns.Text(primary_key=True, required=False)
        is_simulator = columns.Boolean(primary_key=True, required=True)
        branch = columns.Text(primary_key=True, required=True)
        version_name = columns.Text(primary_key=True, required=False)
        last_run = columns.DateTime(required=True)

    class ByModel(Model, ConfigurationModel):
        __table_name__ = 'configs_by_model_with_branch'
        model = columns.Text(partition_key=True, required=True)
        version = columns.Integer(primary_key=True, required=True)
        style = columns.Text(primary_key=True, required=False)
        platform = columns.Text(primary_key=True, required=True)
        flavor = columns.Text(primary_key=True, required=False)
        architecture = columns.Text(primary_key=True, required=True)
        is_simulator = columns.Boolean(primary_key=True, required=True)
        branch = columns.Text(primary_key=True, required=True)
        version_name = columns.Text(primary_key=True, required=False)
        last_run = columns.DateTime(required=True)

    def __init__(self, redis, cassandra, cache_timeout=60 * 60 * 24 * 14):
        assert redis
        assert cassandra

        self.redis = redis
        self.cassandra = cassandra
        self.cache_timeout = cache_timeout

        with self:
            self.cassandra.create_table(self.ByPlatform)
            self.cassandra.create_table(self.ByPlatformAndVersion)
            self.cassandra.create_table(self.ByArchitecture)
            self.cassandra.create_table(self.ByModel)

            for configuration in self.cassandra.select_from_table(self.ByPlatform.__table_name__):
                if configuration.last_run >= datetime.utcfromtimestamp(int(time.time() - cache_timeout)):
                    self._register_in_redis(configuration.to_configuration(), configuration.branch, configuration.last_run)

    def __enter__(self):
        self.cassandra.__enter__()

    def __exit__(self, *args, **kwargs):
        self.cassandra.__exit__(*args, **kwargs)

    @classmethod
    def _convert_to_redis_key(cls, configuration, branch):
        return 'configs_with_branch:{}:{}:{}:{}:{}:{}:{}:{}:{}'.format(
            configuration.platform or '*', '*' if configuration.is_simulator is None else (1 if configuration.is_simulator else 0),
            '*' if configuration.version is None else Configuration.integer_to_version(configuration.version),
            configuration.version_name or '*',
            configuration.architecture or '*', configuration.model or '*',
            configuration.style or '*', configuration.flavor or '*',
            branch,
        )

    def _register_in_redis(self, configuration, branch, timestamp):
        if isinstance(timestamp, datetime):
            timestamp = calendar.timegm(timestamp.timetuple())
        expiration = int(self.cache_timeout - (time.time() - int(timestamp)))
        if expiration > 0:
            sdk = configuration.sdk
            try:
                configuration.sdk = None
                self.redis.set(self._convert_to_redis_key(configuration, branch), configuration.to_json(), ex=expiration)
            finally:
                configuration.sdk = sdk

    def register_configuration(self, configuration, branch=None, timestamp=time.time()):
        if not isinstance(configuration, Configuration):
            raise TypeError(f'Expected type {Configuration}, got {type(configuration)}')
        if not configuration.is_complete():
            raise TypeError('Cannot register a partial configuration')
        if not isinstance(timestamp, datetime):
            timestamp = datetime.utcfromtimestamp(int(timestamp))

        branch = branch or CommitContext.DEFAULT_BRANCH_KEY

        with self:
            tables_to_insert_to = [self.ByPlatform.__table_name__, self.ByPlatformAndVersion.__table_name__, self.ByArchitecture.__table_name__]
            if configuration.model is not None:
                tables_to_insert_to.append(self.ByModel.__table_name__)
            for table in tables_to_insert_to:
                self.cassandra.insert_row(
                    table, branch=branch,
                    platform=configuration.platform, is_simulator=configuration.is_simulator,
                    version=configuration.version, version_name=configuration.version_name or '',
                    architecture=configuration.architecture, model=configuration.model or '',
                    style=configuration.style or '', flavor=configuration.flavor or '',
                    last_run=timestamp,
                )

        self._register_in_redis(configuration, branch, timestamp)

    def search_for_configuration(self, configuration=Configuration(), branch=None):
        if not isinstance(configuration, Configuration):
            raise TypeError(f'Expected type {Configuration}, got {type(configuration)}')

        kwargs = dict(branch=branch or CommitContext.DEFAULT_BRANCH_KEY)
        for member in Configuration.REQUIRED_MEMBERS + Configuration.OPTIONAL_MEMBERS:
            if getattr(configuration, member):
                kwargs[member] = getattr(configuration, member)

        if 'platform' in kwargs and 'version' in kwargs:
            table = self.ByPlatformAndVersion
        elif 'platform' in kwargs:
            table = self.ByPlatform
        elif 'architecture' in kwargs:
            table = self.ByArchitecture
        elif 'model' in kwargs:
            table = self.ByModel
        else:
            # Platforms rarely expire, so we can do a decent job of wildcard matching expired configurations
            # if we try all platforms in the cache.
            platforms = set([config.platform for config in self.search_for_recent_configuration(branch=branch)])
            with self:
                result = []
                for platform in platforms:
                    configuration.platform = platform
                    result.extend(self.search_for_configuration(configuration, branch))
                configuration.platform = None
                return result

        with self:
            return [model.to_configuration() for model in self.cassandra.select_from_table(table.__table_name__, **kwargs)]

    def search_for_recent_configuration(self, configuration=Configuration(), branch=None):
        if not isinstance(configuration, Configuration):
            raise TypeError(f'Expected type {Configuration}, got {type(configuration)}')

        configurations = []
        for key in self.redis.scan_iter(self._convert_to_redis_key(configuration, branch or CommitContext.DEFAULT_BRANCH_KEY)):
            candidate = Configuration.from_json(self.redis.get(key.decode('utf-8')).decode('utf-8'))
            if candidate == configuration:
                configurations.append(candidate)
        return configurations

    def insert_row_with_configuration(self, table_name, configuration, **kwargs):
        if not isinstance(configuration, Configuration):
            raise TypeError(f'Expected type {Configuration}, got {type(configuration)}')
        if not configuration.is_complete():
            raise TypeError(f'Cannot insert to {table_name} with a partial configuration')

        with self:
            attributes_dict = OrderedDict()
            for member in Configuration.OPTIONAL_MEMBERS:
                if getattr(configuration, member) is not None:
                    attributes_dict[member] = getattr(configuration, member)

            return self.cassandra.insert_row(
                table_name,
                platform=configuration.platform, is_simulator=configuration.is_simulator,
                version=configuration.version,
                architecture=configuration.architecture,
                attributes=json.dumps(attributes_dict),
                **kwargs)

    def select_from_table_with_configurations(self, table_name, configurations=None, branch=None, recent=True, limit=100, **kwargs):
        if not isinstance(configurations, Iterable):
            raise TypeError('Expected configurations to be iterable')
        if not configurations:
            configurations.append(Configuration())

        with self:
            complete_configurations = set()
            for config in configurations:
                if not isinstance(config, Configuration):
                    raise TypeError(f'Expected type {Configuration}, got {type(config)}')
                if config.is_complete():
                    complete_configurations.add(config)
                elif recent:
                    [complete_configurations.add(element) for element in self.search_for_recent_configuration(config, branch=branch)]
                else:
                    [complete_configurations.add(element) for element in self.search_for_configuration(config, branch=branch)]

            results = {}
            for configuration in complete_configurations:
                attributes_dict = OrderedDict()
                for member in Configuration.OPTIONAL_MEMBERS:
                    if getattr(configuration, member) is not None:
                        attributes_dict[member] = getattr(configuration, member)

                rows = self.cassandra.select_from_table(
                    table_name,
                    platform=configuration.platform, is_simulator=configuration.is_simulator,
                    version=configuration.version,
                    architecture=configuration.architecture,
                    attributes=json.dumps(attributes_dict),
                    limit=limit,
                    branch=branch or CommitContext.DEFAULT_BRANCH_KEY,
                    **kwargs)
                if len(rows) == 0:
                    continue

                for row in rows:
                    full_config = row.to_configuration()
                    if not results.get(full_config):
                        results[full_config] = []
                    results[full_config].append(row)

            return results
