# coding=utf-8
from __future__ import unicode_literals, print_function, absolute_import

import functools
import threading

import itertools
import jsonschema
import six
import elasticsearch
import elasticsearch.helpers
from django.conf import settings

from message.utils.es import load_mapping


class ESVersion(object):
    V1 = 'v1'
    V2 = 'v2'


class ESTableSchema(object):
    def __init__(self, table_name, mapping_v1_name, mapping_v2_name):
        assert isinstance(table_name, six.string_types)
        assert isinstance(mapping_v1_name, six.string_types)
        self.table_name = table_name
        self.mapping_v1_name = mapping_v1_name
        self.mapping_v2_name = mapping_v2_name

    def __repr__(self):
        return '{}(table_name={})'.format(
            self.__class__.__name__,
            self.table_name,
        )

    def __reduce__(self):
        raise Exception('unserializable')

    def __reduce_ex__(self, *args, **kwargs):
        raise Exception('unserializable')


table_message = ESTableSchema(
    table_name='message',
    mapping_v1_name='message.v1',
    mapping_v2_name='message.v2',
)
table_conversation = ESTableSchema(
    table_name='conversation',
    mapping_v1_name='conversation.v1',
    mapping_v2_name='conversation.v2',
)
table_schema_map = {
    ts.table_name: ts
    for ts in [table_conversation, table_message]
}

_config_schema = {
    '$schema': 'http://json-schema.org/draft-04/schema#',
    'type': 'object',
    'properties': {
        'order': {
            'type': 'array',
            'items': {
                'type': 'string',
                'minItems': 1,
                'maxItems': 2,
                'uniqueItems': True,
            }
        },
        'database': {
            'type': 'array',
            'items': {
                'type': 'object',
                'properties': {
                    'name': {'type': 'string'},
                    'es_version': {'enum': [ESVersion.V1, ESVersion.V2]},
                    'hosts': {'type': 'array'},
                    'table': {
                        'type': 'array',
                        'items': {
                            'type': 'object',
                            'properties': {
                                'name': {
                                    'type': 'string',
                                    'enum': list(table_schema_map),
                                },
                                'index': {'type': 'string'},
                                'doc_type': {'type': 'string'},
                            },
                            'required': ['name', 'index', 'doc_type'],
                        }
                    }
                },
                'required': ['name', 'es_version', 'hosts', 'table'],
            },
        },
    },
    'required': ['order', 'database'],
}


class ESTableConfig(object):
    def __init__(self, name, index, doc_type):
        assert isinstance(name, six.string_types)
        assert isinstance(index, six.string_types)
        assert isinstance(doc_type, six.string_types)
        self.name = name
        self.index = index
        self.doc_type = doc_type


class ESDatabaseConfig(object):
    def __init__(self, name, es_version, hosts, table_list):
        self.name = name
        self.es_version = es_version
        self.hosts = hosts
        self.table_list = table_list
        self.__table_map = {
            table.name: table
            for table in table_list
        }

    def __getitem__(self, item):
        assert isinstance(item, ESTableSchema)
        return self.__table_map[item.table_name]


class Config(object):
    def __init__(self, config_data):
        jsonschema.validate(config_data, _config_schema)
        self.config_data = config_data

        order = config_data['order']
        assert isinstance(order, list)
        self.order = order

        database_list = []
        for database_config in config_data['database']:
            table_list = []
            for table_config in database_config['table']:
                table_list.append(ESTableConfig(
                    name=table_config['name'],
                    index=table_config['index'],
                    doc_type=table_config['doc_type'],
                ))
            database_list.append(ESDatabaseConfig(
                name=database_config['name'],
                es_version=database_config['es_version'],
                hosts=database_config['hosts'],
                table_list=table_list,
            ))
        self.database_list = database_list
        self.__database_map = {
            database.name: database
            for database in database_list
        }

    def get_database_config(self, name):
        return self.__database_map[name]


config = Config(settings.ES_MSG)


class ESBulkAction(object):
    INDEX = '_index'
    DOC_TYPE = '_type'

    def __init__(self, table=None, params=None):
        params = params or {}
        if self.INDEX in params or self.DOC_TYPE in params:
            raise ValueError('params contains {} or {}'.format(self.INDEX, self.DOC_TYPE))
        self.table = table
        self.params = params


class ESOperator(object):
    def search(self, table, **kwargs):
        raise NotImplementedError

    def index(self, table, body, **kwargs):
        raise NotImplementedError

    def mget(self, table, body, **kwargs):
        raise NotImplementedError

    def bulk_single(self, action):
        raise NotImplementedError

    def helper_bulk(self, action_iter, **kwargs):
        raise NotImplementedError

    def helper_scan(self, table, **kwargs):
        raise NotImplementedError

    def alter_table(self, table, drop_if_exists=False):
        raise NotImplementedError


def _create_es_client(hosts):
    if settings.DEBUG:
        es = elasticsearch.Elasticsearch(
            hosts=hosts,
            # no sniffing
            sniff_on_start=False,
            sniff_on_connection_fail=False
        )
    else:
        es = elasticsearch.Elasticsearch(
            hosts=hosts,
            # sniff before doing anything
            sniff_on_start=True,
            # refresh nodes after a node fails to respond
            sniff_on_connection_fail=True,
            # and also every 60 seconds
            sniffer_timeout=60,
            sniff_timeout=1
        )
    return es


class ESOperatorImpl(ESOperator):
    INDEX = 'index'
    DOC_TYPE = 'doc_type'

    def __init__(self, database_name):
        database_config = config.get_database_config(database_name)
        assert isinstance(database_config, ESDatabaseConfig)
        self.client = _create_es_client(hosts=database_config.hosts)
        self.database_config = database_config

    def get_table_index(self, table):
        return self.database_config[table].index

    def get_table_doc_type(self, table):
        return self.database_config[table].doc_type

    def params_add_table(self, table, params):
        assert isinstance(params, dict)
        if self.INDEX in params or self.DOC_TYPE in params:
            raise ValueError('params contains {} or {}'.format(self.INDEX, self.DOC_TYPE))
        table_config = self.database_config[table]
        params[self.INDEX] = table_config.index
        params[self.DOC_TYPE] = table_config.doc_type
        return params

    def bulk_action_to_dict(self, bulk_action):
        assert isinstance(bulk_action, ESBulkAction)
        d = dict(bulk_action.params)
        if bulk_action.table:
            table_config = self.database_config[bulk_action.table]
            d[bulk_action.INDEX] = table_config.index
            d[bulk_action.DOC_TYPE] = table_config.doc_type
        return d

    def search(self, table, **kwargs):
        assert isinstance(table, ESTableSchema)
        return self.client.search(
            index=self.get_table_index(table),
            doc_type=self.get_table_doc_type(table),
            **kwargs
        )

    def index(self, table, body, **kwargs):
        assert isinstance(table, ESTableSchema)
        return self.client.index(
            index=self.get_table_index(table),
            doc_type=self.get_table_doc_type(table),
            body=body,
            **kwargs
        )

    def mget(self, table, body, **kwargs):
        assert isinstance(table, ESTableSchema)
        return self.client.mget(
            index=self.get_table_index(table),
            doc_type=self.get_table_doc_type(table),
            body=body,
            **kwargs
        )

    def bulk_single(self, action):
        assert isinstance(action, ESBulkAction)
        action_dict = self.bulk_action_to_dict(action)
        expanded = elasticsearch.helpers.expand_action(action_dict)
        if expanded[1] is None:
            bulk_actions = (expanded[0],)
        else:
            bulk_actions = expanded

        self.client.bulk(bulk_actions)

    def helper_scan(self, table, **kwargs):
        params = dict(kwargs)
        self.params_add_table(table=table, params=params)
        return elasticsearch.helpers.scan(
            client=self.client,
            **params
        )

    def helper_bulk(self, action_iter, **kwargs):
        return elasticsearch.helpers.bulk(
            client=self.client,
            actions=itertools.imap(self.bulk_action_to_dict, action_iter),
            **kwargs
        )

    def alter_table(self, table, drop_if_exists=False):
        assert isinstance(table, ESTableSchema)

        if self.database_config.es_version == ESVersion.V1:
            mapping_name = table.mapping_v1_name
        elif self.database_config.es_version == ESVersion.V2:
            mapping_name = table.mapping_v2_name
        else:
            raise Exception('invalid es_version: {}'.format(self.database_config.es_version))
        mapping = load_mapping(mapping_name)

        cl = self.client.indices
        index = self.get_table_index(table)
        doc_type = self.get_table_doc_type(table)

        if not cl.exists(index=index):
            cl.create(index=index)

        if cl.exists_type(index=index, doc_type=doc_type) and drop_if_exists:
            cl.delete_mapping(index=index, doc_type=doc_type)

        return cl.put_mapping(index=[index], doc_type=doc_type, body=mapping)


class ESOperationType(object):
    DEFAULT_ONLY = 1
    EXTENSIVE = 2


def es_operation_type(optype):
    def decorator(f):
        name = f.__name__
        if optype == ESOperationType.DEFAULT_ONLY:
            @functools.wraps(f)
            def wrapper(self, *args, **kwargs):
                return getattr(self.default_operator, name)(*args, **kwargs)

            return wrapper
        elif optype == ESOperationType.EXTENSIVE:
            @functools.wraps(f)
            def wrapper(self, *args, **kwargs):
                try:
                    return getattr(self.default_operator, name)(*args, **kwargs)
                finally:
                    getattr(self.secondary_operator, name)(*args, **kwargs)

            return wrapper
        else:
            raise Exception('invalid operation type: {}'.format(optype))

    return decorator


class ESOperatorCombined(ESOperator):
    def __init__(self, default, secondary):
        assert isinstance(default, ESOperator)
        assert isinstance(secondary, ESOperator)
        self.default_operator = default
        self.secondary_operator = secondary

    @es_operation_type(ESOperationType.DEFAULT_ONLY)
    def search(self, table, **kwargs):
        raise NotImplementedError

    @es_operation_type(ESOperationType.EXTENSIVE)
    def index(self, table, body, **kwargs):
        raise NotImplementedError

    @es_operation_type(ESOperationType.DEFAULT_ONLY)
    def mget(self, table, body, **kwargs):
        raise NotImplementedError

    @es_operation_type(ESOperationType.EXTENSIVE)
    def bulk_single(self, action):
        raise NotImplementedError

    @es_operation_type(ESOperationType.DEFAULT_ONLY)
    def helper_scan(self, table, **kwargs):
        raise NotImplementedError

    def alter_table(self, table, drop_if_exists=False):
        raise NotImplementedError('alter_table does not work for ESOperatorCombined')


def create_esop_for_database(database_name):
    return ESOperatorImpl(database_name=database_name)


def _create_esop():
    """
    :rtype: ESOperator
    """
    esop_list = [
        create_esop_for_database(database_name)
        for database_name in config.order
    ]
    if len(esop_list) == 1:
        return esop_list[0]
    elif len(esop_list) == 2:
        default, secondary = esop_list
        return ESOperatorCombined(default=default, secondary=secondary)
    else:
        raise Exception('impossible')


_esop_instance_lock = threading.Lock()
_esop_instance = None
_esop_migrate_instance = None


def get_esop():
    global _esop_instance
    if _esop_instance is None:
        with _esop_instance_lock:
            if _esop_instance is None:
                _esop_instance = _create_esop()
    return _esop_instance


def get_migrate_esop():
    global _esop_migrate_instance
    if _esop_migrate_instance is None:
        with _esop_instance_lock:
            if _esop_migrate_instance is None:
                _esop_migrate_instance = create_esop_for_database('db3')
    return _esop_migrate_instance

