#
# api.py
#
# This source file is part of the FoundationDB open source project
#
# Copyright 2013-2018 Apple Inc. and the FoundationDB project authors
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
#     http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#

import random
import struct

import fdb
import fdb.tuple

from bindingtester import FDB_API_VERSION
from bindingtester import util
from bindingtester.tests import Test, Instruction, InstructionSet, ResultSpecification
from bindingtester.tests import test_util

fdb.api_version(FDB_API_VERSION)


class ApiTest(Test):
    def __init__(self, subspace):
        super(ApiTest, self).__init__(subspace)
        self.workspace = self.subspace['workspace']  # The keys and values here must match between subsequent runs of the same test
        self.scratch = self.subspace['scratch']  # The keys and values here can differ between runs
        self.stack_subspace = self.subspace['stack']

        self.versionstamped_values = self.scratch['versionstamped_values']
        self.versionstamped_values_2 = self.scratch['versionstamped_values_2']
        self.versionstamped_keys = self.scratch['versionstamped_keys']

    def setup(self, args):
        self.stack_size = 0
        self.string_depth = 0
        self.key_depth = 0
        self.max_keys = 1000

        self.has_version = False
        self.can_set_version = True
        self.can_get_commit_version = False
        self.can_use_key_selectors = True

        self.generated_keys = []
        self.outstanding_ops = []
        self.random = test_util.RandomGenerator(args.max_int_bits, args.api_version, args.types)
        self.api_version = args.api_version

    def add_stack_items(self, num):
        self.stack_size += num
        self.string_depth = 0
        self.key_depth = 0

    def add_strings(self, num):
        self.stack_size += num
        self.string_depth += num
        self.key_depth = 0

    def add_keys(self, num):
        self.stack_size += num
        self.string_depth += num
        self.key_depth += num

    def remove(self, num):
        self.stack_size -= num
        self.string_depth = max(0, self.string_depth - num)
        self.key_depth = max(0, self.key_depth - num)

        self.outstanding_ops = [i for i in self.outstanding_ops if i[0] <= self.stack_size]

    def ensure_string(self, instructions, num):
        while self.string_depth < num:
            instructions.push_args(self.random.random_string(random.randint(0, 100)))
            self.add_strings(1)

        self.remove(num)

    def choose_key(self):
        if random.random() < float(len(self.generated_keys)) / self.max_keys:
            tup = random.choice(self.generated_keys)
            if random.random() < 0.3:
                return self.workspace.pack(tup[0:random.randint(0, len(tup))])

            return self.workspace.pack(tup)

        tup = self.random.random_tuple(5)
        self.generated_keys.append(tup)

        return self.workspace.pack(tup)

    def ensure_key(self, instructions, num):
        while self.key_depth < num:
            instructions.push_args(self.choose_key())
            self.add_keys(1)

        self.remove(num)

    def ensure_key_value(self, instructions):
        if self.string_depth == 0:
            instructions.push_args(self.choose_key(), self.random.random_string(random.randint(0, 100)))

        elif self.string_depth == 1 or self.key_depth == 0:
            self.ensure_key(instructions, 1)
            self.remove(1)

        else:
            self.remove(2)

    def preload_database(self, instructions, num):
        for i in range(num):
            self.ensure_key_value(instructions)
            instructions.append('SET')

            if i % 100 == 99:
                test_util.blocking_commit(instructions)

        test_util.blocking_commit(instructions)
        self.add_stack_items(1)

    def wait_for_reads(self, instructions):
        while len(self.outstanding_ops) > 0 and self.outstanding_ops[-1][0] <= self.stack_size:
            read = self.outstanding_ops.pop()
            # print '%d. waiting for read at instruction %r' % (len(instructions), read)
            test_util.to_front(instructions, self.stack_size - read[0])
            instructions.append('WAIT_FUTURE')

    def generate(self, args, thread_number):
        instructions = InstructionSet()

        op_choices = ['NEW_TRANSACTION', 'COMMIT']

        reads = ['GET', 'GET_KEY', 'GET_RANGE', 'GET_RANGE_STARTS_WITH', 'GET_RANGE_SELECTOR']
        mutations = ['SET', 'CLEAR', 'CLEAR_RANGE', 'CLEAR_RANGE_STARTS_WITH', 'ATOMIC_OP']
        snapshot_reads = [x + '_SNAPSHOT' for x in reads]
        database_reads = [x + '_DATABASE' for x in reads]
        database_mutations = [x + '_DATABASE' for x in mutations]
        mutations += ['VERSIONSTAMP']
        versions = ['GET_READ_VERSION', 'SET_READ_VERSION', 'GET_COMMITTED_VERSION']
        snapshot_versions = ['GET_READ_VERSION_SNAPSHOT']
        tuples = ['TUPLE_PACK', 'TUPLE_UNPACK', 'TUPLE_RANGE', 'TUPLE_SORT', 'SUB', 'ENCODE_FLOAT', 'ENCODE_DOUBLE', 'DECODE_DOUBLE', 'DECODE_FLOAT']
        if 'versionstamp' in args.types:
            tuples.append('TUPLE_PACK_WITH_VERSIONSTAMP')
        resets = ['ON_ERROR', 'RESET', 'CANCEL']
        read_conflicts = ['READ_CONFLICT_RANGE', 'READ_CONFLICT_KEY']
        write_conflicts = ['WRITE_CONFLICT_RANGE', 'WRITE_CONFLICT_KEY', 'DISABLE_WRITE_CONFLICT']

        op_choices += reads
        op_choices += mutations
        op_choices += snapshot_reads
        op_choices += database_reads
        op_choices += database_mutations
        op_choices += versions
        op_choices += snapshot_versions
        op_choices += tuples
        op_choices += read_conflicts
        op_choices += write_conflicts
        op_choices += resets

        idempotent_atomic_ops = [u'BIT_AND', u'BIT_OR', u'MAX', u'MIN', u'BYTE_MIN', u'BYTE_MAX']
        atomic_ops = idempotent_atomic_ops + [u'ADD', u'BIT_XOR', u'APPEND_IF_FITS']

        if args.concurrency > 1:
            self.max_keys = random.randint(100, 1000)
        else:
            self.max_keys = random.randint(100, 10000)

        instructions.append('NEW_TRANSACTION')
        instructions.append('GET_READ_VERSION')

        self.preload_database(instructions, self.max_keys)

        instructions.setup_complete()

        for i in range(args.num_ops):
            op = random.choice(op_choices)
            index = len(instructions)
            read_performed = False

            # print 'Adding instruction %s at %d' % (op, index)

            if args.concurrency == 1 and (op in database_mutations):
                self.wait_for_reads(instructions)
                test_util.blocking_commit(instructions)
                self.can_get_commit_version = False
                self.add_stack_items(1)

            if op in resets or op == 'NEW_TRANSACTION':
                if args.concurrency == 1:
                    self.wait_for_reads(instructions)

                self.outstanding_ops = []

            if op == 'NEW_TRANSACTION':
                instructions.append(op)
                self.can_get_commit_version = True
                self.can_set_version = True
                self.can_use_key_selectors = True

            elif op == 'ON_ERROR':
                instructions.push_args(random.randint(0, 5000))
                instructions.append(op)

                self.outstanding_ops.append((self.stack_size, len(instructions) - 1))
                if args.concurrency == 1:
                    self.wait_for_reads(instructions)

                instructions.append('NEW_TRANSACTION')
                self.can_get_commit_version = True
                self.can_set_version = True
                self.can_use_key_selectors = True
                self.add_strings(1)

            elif op == 'GET' or op == 'GET_SNAPSHOT' or op == 'GET_DATABASE':
                self.ensure_key(instructions, 1)
                instructions.append(op)
                self.add_strings(1)
                self.can_set_version = False
                read_performed = True

            elif op == 'GET_KEY' or op == 'GET_KEY_SNAPSHOT' or op == 'GET_KEY_DATABASE':
                if op.endswith('_DATABASE') or self.can_use_key_selectors:
                    self.ensure_key(instructions, 1)
                    instructions.push_args(self.workspace.key())
                    instructions.push_args(*self.random.random_selector_params())
                    test_util.to_front(instructions, 3)
                    instructions.append(op)

                    # Don't add key here because we may be outside of our prefix
                    self.add_strings(1)
                    self.can_set_version = False
                    read_performed = True

            elif op == 'GET_RANGE' or op == 'GET_RANGE_SNAPSHOT' or op == 'GET_RANGE_DATABASE':
                self.ensure_key(instructions, 2)
                range_params = self.random.random_range_params()
                instructions.push_args(*range_params)
                test_util.to_front(instructions, 4)
                test_util.to_front(instructions, 4)
                instructions.append(op)

                if range_params[0] >= 1 and range_params[0] <= 1000:  # avoid adding a string if the limit is large
                    self.add_strings(1)
                else:
                    self.add_stack_items(1)

                self.can_set_version = False
                read_performed = True

            elif op == 'GET_RANGE_STARTS_WITH' or op == 'GET_RANGE_STARTS_WITH_SNAPSHOT' or op == 'GET_RANGE_STARTS_WITH_DATABASE':
                # TODO: not tested well
                self.ensure_key(instructions, 1)
                range_params = self.random.random_range_params()
                instructions.push_args(*range_params)
                test_util.to_front(instructions, 3)
                instructions.append(op)

                if range_params[0] >= 1 and range_params[0] <= 1000:  # avoid adding a string if the limit is large
                    self.add_strings(1)
                else:
                    self.add_stack_items(1)

                self.can_set_version = False
                read_performed = True

            elif op == 'GET_RANGE_SELECTOR' or op == 'GET_RANGE_SELECTOR_SNAPSHOT' or op == 'GET_RANGE_SELECTOR_DATABASE':
                if op.endswith('_DATABASE') or self.can_use_key_selectors:
                    self.ensure_key(instructions, 2)
                    instructions.push_args(self.workspace.key())
                    range_params = self.random.random_range_params()
                    instructions.push_args(*range_params)
                    instructions.push_args(*self.random.random_selector_params())
                    test_util.to_front(instructions, 6)
                    instructions.push_args(*self.random.random_selector_params())
                    test_util.to_front(instructions, 9)
                    instructions.append(op)

                    if range_params[0] >= 1 and range_params[0] <= 1000:  # avoid adding a string if the limit is large
                        self.add_strings(1)
                    else:
                        self.add_stack_items(1)

                    self.can_set_version = False
                    read_performed = True

            elif op == 'GET_READ_VERSION' or op == 'GET_READ_VERSION_SNAPSHOT':
                instructions.append(op)
                self.has_version = self.can_set_version
                self.add_strings(1)

            elif op == 'SET' or op == 'SET_DATABASE':
                self.ensure_key_value(instructions)
                instructions.append(op)
                if op == 'SET_DATABASE':
                    self.add_stack_items(1)

            elif op == 'SET_READ_VERSION':
                if self.has_version and self.can_set_version:
                    instructions.append(op)
                    self.can_set_version = False

            elif op == 'CLEAR' or op == 'CLEAR_DATABASE':
                self.ensure_key(instructions, 1)
                instructions.append(op)
                if op == 'CLEAR_DATABASE':
                    self.add_stack_items(1)

            elif op == 'CLEAR_RANGE' or op == 'CLEAR_RANGE_DATABASE':
                # Protect against inverted range
                key1 = self.workspace.pack(self.random.random_tuple(5))
                key2 = self.workspace.pack(self.random.random_tuple(5))

                if key1 > key2:
                    key1, key2 = key2, key1

                instructions.push_args(key1, key2)

                instructions.append(op)
                if op == 'CLEAR_RANGE_DATABASE':
                    self.add_stack_items(1)

            elif op == 'CLEAR_RANGE_STARTS_WITH' or op == 'CLEAR_RANGE_STARTS_WITH_DATABASE':
                self.ensure_key(instructions, 1)
                instructions.append(op)
                if op == 'CLEAR_RANGE_STARTS_WITH_DATABASE':
                    self.add_stack_items(1)

            elif op == 'ATOMIC_OP' or op == 'ATOMIC_OP_DATABASE':
                self.ensure_key_value(instructions)
                if op == 'ATOMIC_OP' or args.concurrency > 1:
                    instructions.push_args(random.choice(atomic_ops))
                else:
                    instructions.push_args(random.choice(idempotent_atomic_ops))

                instructions.append(op)
                if op == 'ATOMIC_OP_DATABASE':
                    self.add_stack_items(1)

            elif op == 'VERSIONSTAMP':
                rand_str1 = self.random.random_string(100)
                key1 = self.versionstamped_values.pack((rand_str1,))
                key2 = self.versionstamped_values_2.pack((rand_str1,))

                split = random.randint(0, 70)
                prefix = self.random.random_string(20 + split)
                if prefix.endswith('\xff'):
                    # Necessary to make sure that the SET_VERSIONSTAMPED_VALUE check
                    # correctly finds where the version is supposed to fit in.
                    prefix += '\x00'
                suffix = self.random.random_string(70 - split)
                rand_str2 = prefix + fdb.tuple.Versionstamp._UNSET_TR_VERSION + suffix
                key3 = self.versionstamped_keys.pack() + rand_str2
                index = len(self.versionstamped_keys.pack()) + len(prefix)
                key3 = self.versionstamp_key(key3, index)

                instructions.push_args(u'SET_VERSIONSTAMPED_VALUE',
                                       key1,
                                       self.versionstamp_value(fdb.tuple.Versionstamp._UNSET_TR_VERSION + rand_str2))
                instructions.append('ATOMIC_OP')

                if args.api_version >= 520:
                    instructions.push_args(u'SET_VERSIONSTAMPED_VALUE', key2, self.versionstamp_value(rand_str2, len(prefix)))
                    instructions.append('ATOMIC_OP')

                instructions.push_args(u'SET_VERSIONSTAMPED_KEY', key3, rand_str1)
                instructions.append('ATOMIC_OP')
                self.can_use_key_selectors = False

            elif op == 'READ_CONFLICT_RANGE' or op == 'WRITE_CONFLICT_RANGE':
                self.ensure_key(instructions, 2)
                instructions.append(op)
                self.add_strings(1)

            elif op == 'READ_CONFLICT_KEY' or op == 'WRITE_CONFLICT_KEY':
                self.ensure_key(instructions, 1)
                instructions.append(op)
                self.add_strings(1)

            elif op == 'DISABLE_WRITE_CONFLICT':
                instructions.append(op)

            elif op == 'COMMIT':
                if args.concurrency == 1 or i < self.max_keys or random.random() < 0.9:
                    if args.concurrency == 1:
                        self.wait_for_reads(instructions)
                    test_util.blocking_commit(instructions)
                    self.can_get_commit_version = False
                    self.add_stack_items(1)
                    self.can_set_version = True
                    self.can_use_key_selectors = True
                else:
                    instructions.append(op)
                    self.add_strings(1)

            elif op == 'RESET':
                instructions.append(op)
                self.can_get_commit_version = False
                self.can_set_version = True
                self.can_use_key_selectors = True

            elif op == 'CANCEL':
                instructions.append(op)
                self.can_set_version = False

            elif op == 'GET_COMMITTED_VERSION':
                if self.can_get_commit_version:
                    do_commit = random.random() < 0.5

                    if do_commit:
                        instructions.append('COMMIT')
                        instructions.append('WAIT_FUTURE')
                        self.add_stack_items(1)

                    instructions.append(op)

                    self.has_version = True
                    self.add_strings(1)

                    if do_commit:
                        instructions.append('RESET')
                        self.can_get_commit_version = False
                        self.can_set_version = True
                        self.can_use_key_selectors = True

            elif op == 'TUPLE_PACK' or op == 'TUPLE_RANGE':
                tup = self.random.random_tuple(10)
                instructions.push_args(len(tup), *tup)
                instructions.append(op)
                if op == 'TUPLE_PACK':
                    self.add_strings(1)
                else:
                    self.add_strings(2)

            elif op == 'TUPLE_PACK_WITH_VERSIONSTAMP':
                tup = (self.random.random_string(20),) + self.random.random_tuple(10, incomplete_versionstamps=True)
                prefix = self.versionstamped_keys.pack()
                instructions.push_args(prefix, len(tup), *tup)
                instructions.append(op)
                self.add_strings(1)

                versionstamp_param = prefix + fdb.tuple.pack(tup)
                first_incomplete = versionstamp_param.find(fdb.tuple.Versionstamp._UNSET_TR_VERSION)
                second_incomplete = -1 if first_incomplete < 0 else \
                    versionstamp_param.find(fdb.tuple.Versionstamp._UNSET_TR_VERSION, first_incomplete + len(fdb.tuple.Versionstamp._UNSET_TR_VERSION) + 1)

                # If there is exactly one incomplete versionstamp, perform the versionstamp operation.
                if first_incomplete >= 0 and second_incomplete < 0:
                    rand_str = self.random.random_string(100)

                    instructions.push_args(rand_str)
                    test_util.to_front(instructions, 1)
                    instructions.push_args(u'SET_VERSIONSTAMPED_KEY')
                    instructions.append('ATOMIC_OP')

                    if self.api_version >= 520:
                        version_value_key_2 = self.versionstamped_values_2.pack((rand_str,))
                        versionstamped_value = self.versionstamp_value(fdb.tuple.pack(tup), first_incomplete - len(prefix))
                        instructions.push_args(u'SET_VERSIONSTAMPED_VALUE', version_value_key_2, versionstamped_value)
                        instructions.append('ATOMIC_OP')

                    version_value_key = self.versionstamped_values.pack((rand_str,))
                    instructions.push_args(u'SET_VERSIONSTAMPED_VALUE', version_value_key,
                                           self.versionstamp_value(fdb.tuple.Versionstamp._UNSET_TR_VERSION + fdb.tuple.pack(tup)))
                    instructions.append('ATOMIC_OP')
                    self.can_use_key_selectors = False

            elif op == 'TUPLE_UNPACK':
                tup = self.random.random_tuple(10)
                instructions.push_args(len(tup), *tup)
                instructions.append('TUPLE_PACK')
                instructions.append(op)
                self.add_strings(len(tup))

            elif op == 'TUPLE_SORT':
                tups = self.random.random_tuple_list(10, 30)
                for tup in tups:
                    instructions.push_args(len(tup), *tup)
                    instructions.append('TUPLE_PACK')
                instructions.push_args(len(tups))
                instructions.append(op)
                self.add_strings(len(tups))

            # Use SUB to test if integers are correctly unpacked
            elif op == 'SUB':
                a = self.random.random_int() / 2
                b = self.random.random_int() / 2
                instructions.push_args(0, a, b)
                instructions.append(op)
                instructions.push_args(1)
                instructions.append('SWAP')
                instructions.append(op)
                instructions.push_args(1)
                instructions.append('TUPLE_PACK')
                self.add_stack_items(1)

            elif op == 'ENCODE_FLOAT':
                f = self.random.random_float(8)
                f_bytes = struct.pack('>f', f)
                instructions.push_args(f_bytes)
                instructions.append(op)
                self.add_stack_items(1)

            elif op == 'ENCODE_DOUBLE':
                d = self.random.random_float(11)
                d_bytes = struct.pack('>d', d)
                instructions.push_args(d_bytes)
                instructions.append(op)
                self.add_stack_items(1)

            elif op == 'DECODE_FLOAT':
                f = self.random.random_float(8)
                instructions.push_args(fdb.tuple.SingleFloat(f))
                instructions.append(op)
                self.add_strings(1)

            elif op == 'DECODE_DOUBLE':
                d = self.random.random_float(11)
                instructions.push_args(d)
                instructions.append(op)
                self.add_strings(1)

            else:
                assert False, 'Unknown operation: ' + op

            if read_performed and op not in database_reads:
                self.outstanding_ops.append((self.stack_size, len(instructions) - 1))

            if args.concurrency == 1 and (op in database_reads or op in database_mutations):
                instructions.append('WAIT_FUTURE')

        instructions.begin_finalization()

        if args.concurrency == 1:
            self.wait_for_reads(instructions)
            test_util.blocking_commit(instructions)
            self.add_stack_items(1)

        instructions.append('NEW_TRANSACTION')
        instructions.push_args(self.stack_subspace.key())
        instructions.append('LOG_STACK')

        test_util.blocking_commit(instructions)

        return instructions

    @fdb.transactional
    def check_versionstamps(self, tr, begin_key, limit):
        next_begin = None
        incorrect_versionstamps = 0
        for k, v in tr.get_range(begin_key, self.versionstamped_values.range().stop, limit=limit):
            next_begin = k + '\x00'
            random_id = self.versionstamped_values.unpack(k)[0]
            versioned_value = v[10:].replace(fdb.tuple.Versionstamp._UNSET_TR_VERSION, v[:10], 1)

            versioned_key = self.versionstamped_keys.pack() + versioned_value
            if tr[versioned_key] != random_id:
                util.get_logger().error('  INCORRECT VERSIONSTAMP:')
                util.get_logger().error('    %s != %s', repr(tr[versioned_key]), repr(random_id))
                incorrect_versionstamps += 1

            if self.api_version >= 520:
                k2 = self.versionstamped_values_2.pack((random_id,))
                if tr[k2] != versioned_value:
                    util.get_logger().error('  INCORRECT VERSIONSTAMP:')
                    util.get_logger().error('    %s != %s', repr(tr[k2]), repr(versioned_value))
                    incorrect_versionstamps += 1

        return (next_begin, incorrect_versionstamps)

    def validate(self, db, args):
        errors = []

        begin = self.versionstamped_values.range().start
        incorrect_versionstamps = 0

        while begin is not None:
            (begin, current_incorrect_versionstamps) = self.check_versionstamps(db, begin, 100)
            incorrect_versionstamps += current_incorrect_versionstamps

        if incorrect_versionstamps > 0:
            errors.append('There were %d failed version stamp operations' % incorrect_versionstamps)

        return errors

    def get_result_specifications(self):
        return [
            ResultSpecification(self.workspace, global_error_filter=[1007, 1021]),
            ResultSpecification(self.stack_subspace, key_start_index=1, ordering_index=1, global_error_filter=[1007, 1021])
        ]
