from threading import Thread
import os

from context import get_json_value, EasydbException
from hotfolder_modules.util import print_traceback, InternalError, dumpjs
from hotfolder_modules.file_util import to_utf8_unicode, to_utf8_string, write_error
from hotfolder_modules.worker_object_format import search_objects_by_query, set_asset_fields, create_new_object, collect_linked_objects


class HotfolderWorker(Thread):

    def __init__(self, manager, id):
        Thread.__init__(self, name='hotfolder-worker-{0}'.format(id))
        self.manager = manager
        self.id = id
        self.logger = manager.easydb_context.get_logger('pf.plugin.base.hotfolder_worker')

        # map: _system_object_id -> (linked object with asset; bool: was updated)
        self.linked_objects_to_update = {}

        # map: objecttype -> object hash -> id of created linked objects to avoid duplicates
        self.created_objects = {}

    def run(self):
        while True:
            obj = self.manager.queue.get()
            self.process(obj)
            self.manager.queue.task_done()

    def get_connection(self, connection_suffix=None):
        return self.manager.easydb_context.db_connect('hotfolder-worker-{0}{1}'.format(
            '{}-'.format(connection_suffix) if connection_suffix is not None else '',
            self.id
        ))

    # First Upload File, then Create Objects link files and Import via API
    def process(self, obj):
        if len(obj.files) < 1:
            self.logger.warn('No Files provided')
            return

        success = False

        connection = self.get_connection()
        try:
            # check if the session is in cache
            if obj.collection.user_id in self.manager.session_cache:
                session = self.manager.session_cache[obj.collection.user_id]
                self.logger.debug('session for user {} from cache: {}'.format(obj.collection.user_id, session.session_id))
            else:
                session = self.manager.easydb_context.create_session(connection, obj.collection.user_id)
                connection.commit()
                # add new session to the cache
                self.manager.session_cache[obj.collection.user_id] = session
                self.logger.debug('session for user {} from database: {}'.format(obj.collection.user_id, session.session_id))

            result = self.process_files(session, obj)

            if result is not None:
                success = self.process_objects([obj], obj.collection, session, [result])

        except Exception as e:
            self.handle_main_error(obj, e)
        finally:
            connection.close()

        if success:
            obj.remove(self.manager.logger)
        else:
            self.logger.warn('[{}] file {} could not be uploaded, trying again later'.format(
                self.id, to_utf8_unicode(obj.name)))
            obj.unlock(self.manager.logger)

    def process_list(self, objs, collection):

        if len(objs) < 1:
            return

        success = False
        o = -1

        connection = self.get_connection()
        try:
            # check if the session is in cache
            if collection.user_id in self.manager.session_cache:
                session = self.manager.session_cache[collection.user_id]
                self.logger.debug('session for user {} from cache: {}'.format(collection.user_id, session.session_id))
            else:
                session = self.manager.easydb_context.create_session(connection, collection.user_id)
                connection.commit()
                # add new session to the cache
                self.manager.session_cache[collection.user_id] = session
                self.logger.debug('session for user {} from database: {}'.format(collection.user_id, session.session_id))

            valid_objs = []
            eas_metadata = []

            for obj in objs:
                o += 1
                if len(obj.files) < 1:
                    continue

                result = self.process_files(session, obj)

                if result is not None:
                    valid_objs.append(obj)
                    eas_metadata.append(result)

            success = self.process_objects(valid_objs, collection, session, eas_metadata)

        except Exception as e:
            success = False
            self.handle_main_error(objs[o], e)
        finally:
            connection.close()

        if success:
            for obj in objs:
                obj.remove(self.manager.logger)

        else:
            for obj in objs:
                self.logger.warn('[{}] file {} could not be uploaded, trying again later'.format(
                    self.id, to_utf8_unicode(obj.name)))
                obj.unlock(self.manager.logger)

    # For every file upload_file() is called. All returned Meta-Data is Additivly merged
    # (If there is no Meta-Data in the first file it will be taken from the second one)
    def process_files(self, session, obj):
        eas_ids = []
        files = []
        mapped_meta_data = {}
        version_keys = list(obj.files.keys())
        version_keys.sort()

        file_versions = {}

        for v in version_keys:
            versions = obj.files[v]
            eas_version_ids = []
            versions.sort(key=lambda x: x.get_path())

            for file in versions:
                result = self.upload_file(session, file, obj)

                if result is None:
                    return None
                else:
                    eas_id, eas_meta_data = result

                for key in list(eas_meta_data.keys()):
                    if key not in list(mapped_meta_data.keys()) or mapped_meta_data[key] is None:
                        mapped_meta_data[key] = eas_meta_data[key]
                        del eas_meta_data[key]

                if '_objecttype' in list(eas_meta_data.keys()):
                    objecttype = eas_meta_data['_objecttype']

                    for key in list(eas_meta_data[objecttype].keys()):

                        if key not in list(mapped_meta_data[objecttype].keys()) or mapped_meta_data[objecttype][key] is None:
                            mapped_meta_data[objecttype][key] = eas_meta_data[objecttype][key]

                        elif isinstance(mapped_meta_data[objecttype][key], list):
                            mapped_meta_data[objecttype][key].extend(eas_meta_data[objecttype][key])

                eas_version_ids.append(eas_id)

                basename = os.path.splitext(file.name)[0]
                separator = self.manager.get_seperator(basename)
                file_versions = {
                    'eas_id': eas_id,
                    'path': file.get_path(),
                    'filename': file.name,
                    'basename': basename,
                    'basename_without_series': self.manager.basename_without_serial(basename, separator),
                    'size': file.get_size()
                }

            files.append(file_versions)

            eas_ids.append(eas_version_ids)

        return (eas_ids, mapped_meta_data, files)

    # File is Uploaded server returns mapped Meta-Data (Mapping must be defined in Front-End)
    def upload_file(self, session, f, obj):
        try:
            self.logger.debug('[{}] processing file {}'.format(self.id, f))

            if obj.collection.mapping:
                mapping = str(obj.collection.mapping)
            else:
                mapping = None

            mask = obj.collection.mask_name
            objecttype = obj.collection.objecttype
            pool_id = obj.collection.pool_id
            asset = self.manager.easydb_context.put_asset_from_file(
                session,
                f.get_path(),
                f.name,
                mapping,
                mask,
                objecttype,
                pool_id
            )

            self.logger.info('[{}] uploading file {}, mapping={}, mask={}, objecttype={}, pool={}, linked_pool_id={}'.format(
                self.id, to_utf8_unicode(f.get_path()), mapping, mask, objecttype, pool_id, obj.collection.linked_pool_id))

            if '_id' not in asset:
                raise InternalError('_id not found in asset')

            eas_id = asset['_id']

            self.logger.debug('[{}] eas_id = {}'.format(self.id, eas_id))

            if '_mapped_metadata' in asset:
                return (eas_id, asset['_mapped_metadata'])
            else:
                return (eas_id, {})

        except Exception as e:
            self.handle_file_error(f, e)
            return None

    # Creates JSON for calling Server API and creating Objects in easydb based on Mapped Metadata other information and EAS(File)-IDs are added
    def process_objects(self, objs, collection, session, eas_meta_data):
        if len(objs) < 1:
            return True
        if len(objs) != len(eas_meta_data):
            return False

        self.logger.debug(
            '[{}] processing {} objects'.format(self.id, len(objs)))

        # map: filename -> object id -> (version, object) to store search results
        search_results_by_filename = {}

        # map filenames to objects
        filename_objs = {}
        filename_paths = {}

        _field = collection.update_search_field
        _objecttype = collection.objecttype

        # any filenames that caused problem during update (missing or ambiguous search results) need a error.txt file to skip them in the next round
        errors_by_filename = {}

        if _field is not None and collection.update_mode != collection.MODE_INSERT:

            ob_id = 0
            for obj in objs:
                for o in eas_meta_data[ob_id][2]:
                    if not 'filename' in o:
                        continue
                    filename_objs[os.path.basename(o['filename'])] = obj
                    filename_paths[os.path.basename(o['filename'])] = o['filename']
                ob_id += 1

            # perform searches
            filenames = list(filename_objs.keys())
            if len(filenames) > 0:

                separator = None
                filename_basenames = {}
                for f in filenames:
                    basename = os.path.splitext(f)[0]
                    filename_basenames[basename] = f
                    if separator is None:
                        separator = self.manager.get_seperator(basename)

                # search for objects by parts of the filename (necessary because we do not know which part of the filename is used as the identifier)
                # there are 2 or 3 steps, depending on the recognize_series setting:
                #   (1) search for full file
                #   (2) search for file basename
                #   (3) only if recognize_series is true: split by the separator and repeat

                repititions = 2
                if '_nested:' in collection.eas_column_path and separator is not None:
                    repititions = 3
                self.logger.debug('search for filenames/basenames: series separator: "{}" => {} repititions'.format(separator, repititions))

                for i in range(repititions):
                    search_in = []

                    if i == 0:
                        search_in = filenames
                        self.logger.debug('(1) search for filenames: {}'.format(dumpjs(search_in)))
                    elif i == 1:
                        search_in = list(filename_basenames.keys())
                        self.logger.debug('(2) search for basenames: {}'.format(dumpjs(search_in)))
                    elif i == 2:
                        for basename in filename_basenames.keys():
                            other_basename = self.manager.basename_without_serial(basename, separator)
                            if other_basename == '':
                                continue
                            if other_basename in search_in:
                                continue
                            search_in.append(other_basename)

                        self.logger.debug('(3) search for basenames without series number: {}'.format(dumpjs(search_in)))
                        if len(search_in) < 1:
                            break
                    else:
                        break  # should never happen

                    query = {
                        'exclude_fields': [
                            '_standard',
                            '_owner',
                            '_collection',
                            '_score'
                        ],
                        'format': 'long',
                        'generate_rights': False,
                        'include_fields': [
                            _field
                        ],
                        'objecttypes': [
                            _objecttype
                        ],
                        'search': [
                            {
                                'type': 'in',
                                'fields': [
                                    _field
                                ],
                                'in': search_in
                            }
                        ]
                    }

                    result_objects = search_objects_by_query(self, query, collection.user_id)
                    if not isinstance(result_objects, list):
                        continue
                    if len(result_objects) < 1:
                        continue

                    result_found = False

                    # analyze search result, map object ids to search fields
                    for obj in result_objects:
                        if not '_objecttype' in obj:
                            continue
                        if obj['_objecttype'] != _objecttype:
                            continue

                        if not '_mask' in obj:
                            continue
                        _mask = obj['_mask']

                        _field_value = get_json_value(obj, _field)
                        if _field_value is None:
                            continue

                        # if there is already an error for this filename, ignore other search results for the filename
                        if _field_value in errors_by_filename:
                            continue

                        _object = get_json_value(obj, _objecttype)
                        if _object is None:
                            continue

                        _object_id = get_json_value(obj, '{}._id'.format(_objecttype))
                        if _object_id is None:
                            continue

                        _version = get_json_value(obj, '{}._version'.format(_objecttype))
                        if _version is None:
                            continue

                        # increase version for updated objects
                        _object['_version'] = _version + 1

                        _object = {
                            '_mask': _mask,
                            '_objecttype': _objecttype,
                            _objecttype: _object
                        }

                        if not _field_value in search_results_by_filename:
                            self.logger.debug('search result: {} - {} - \'{}\' - {}:{}'.format(
                                _objecttype, _field, _field_value, _object_id, _version))
                            search_results_by_filename[_field_value] = _object
                            result_found = True
                        else:
                            # there already was another object in the search result for this filename -> error file
                            filename = _field_value if i == 0 else filename_basenames[to_utf8_string(_field_value)]
                            err_msg = 'ambiguous search results for filename {}'.format(to_utf8_unicode(filename))
                            self.logger.warn(err_msg)
                            errors_by_filename[filename] = err_msg

                    if result_found:
                        break

            self.logger.debug('search results by filename: {}'.format(dumpjs(search_results_by_filename)))

            # if mode == update: for each filename, where NO object was found, error.txt for this file
            if collection.update_mode == collection.MODE_UPDATE:
                self.logger.debug('update_mode=update. check if for each filename there is a search result')
                for filename in filename_objs:
                    # check if the original filename was found
                    if filename in search_results_by_filename:
                        continue

                    basename_found = False
                    for basename in filename_basenames:
                        # check if the basename of the original filename was found
                        if filename_basenames[basename] in search_results_by_filename:
                            basename_found = True
                            break

                        # check if the basename without the serial part was found
                        other_basename = self.manager.basename_without_serial(basename, separator)
                        if other_basename in search_results_by_filename:
                            basename_found = True
                            break

                    if basename_found:
                        continue

                    err_msg = 'update_mode=update; no search results for filename {}'.format(filename)
                    self.logger.warn(err_msg)
                    errors_by_filename[filename] = err_msg

            # if there were any file errors so far, write error files and return
            if errors_by_filename != {}:
                for filename in errors_by_filename:
                    error_file = '{}.error.txt'.format(
                        os.path.join(collection.directory, to_utf8_unicode(filename_paths[to_utf8_string(filename)])))
                    error_msg = to_utf8_unicode(errors_by_filename[filename])
                    self.logger.debug('error message for {}: \'{}\''.format(error_file, error_msg))
                    write_error(to_utf8_string(error_file), to_utf8_string(error_msg))
                return False

            # collect linked objects with assets that need to be updated
            if search_results_by_filename != {}:
                collect_linked_objects(self, session, search_results_by_filename, collection)

        # iterate and handle collection objects
        collection_objects = {
            '_update': [],
            '_new': []
        }

        ob_id = 0
        for obj in objs:
            self.logger.debug('[{}] processing object {}'.format(self.id, obj))

            new_object = eas_meta_data[ob_id][1]

            add_to_updated_objects = False
            add_to_new_objects = True

            if collection.update_search_field is not None and collection.update_mode != collection.MODE_INSERT:
                self.logger.debug('eas_meta_data[ob_id][2]: {}'.format(dumpjs(eas_meta_data[ob_id][2])))
                for o in eas_meta_data[ob_id][2]:
                    if not 'filename' in o:
                        continue

                    _filename = to_utf8_unicode(os.path.basename(o['filename']))
                    if not _filename in search_results_by_filename:
                        _filename = to_utf8_unicode(os.path.basename(o['basename']))
                        if not _filename in search_results_by_filename:
                            _filename = to_utf8_unicode(os.path.basename(o['basename_without_series']))
                            if not _filename in search_results_by_filename:
                                continue

                    # instead of creating a new object, update the loaded object
                    # update the asset with the new uploaded asset
                    _updated_object = search_results_by_filename[_filename]

                    try:
                        # update asset fields, pass map of linked objects that also need to be updated
                        add_to_new_objects = False
                        add_to_updated_objects = set_asset_fields(self, session, _updated_object, collection, eas_meta_data[ob_id][0], True)
                        break
                    except InternalError as e:
                        err_msg = e.message
                        self.logger.warn(err_msg)
                        errors_by_filename[_filename] = err_msg

            if add_to_updated_objects:
                collection_objects['_update'].append((_updated_object, obj))
            elif add_to_new_objects:
                try:
                    new_obj = create_new_object(self, session, new_object, obj, eas_meta_data[ob_id])
                    if new_obj is not None:
                        collection_objects['_new'].append((new_obj, obj))
                except Exception as e:
                    print_traceback(e, logger=self.logger)
                    self.logger.error('failed to create new object: {}'.format(e))

            ob_id += 1

        if errors_by_filename != {}:
            for filename in errors_by_filename:
                error_file = '{}.error.txt'.format(
                    os.path.join(collection.directory, to_utf8_unicode(filename_paths[to_utf8_string(filename)])))
                error_msg = to_utf8_unicode(errors_by_filename[filename])
                self.logger.debug('error message for {}: \'{}\''.format(error_file, error_msg))
                write_error(to_utf8_string(error_file), to_utf8_string(error_msg))
            return False

        batches = 0
        hotfolder_objects = []

        for key in collection_objects:

            tmp_objects = collection_objects[key]
            objects = []
            objs = []
            for t_obj in tmp_objects:
                objects.append(t_obj[0])
                objs.append(t_obj[1])

            if len(objects) > 0:
                self.manager.logger.info('[{}] import {} objects for collection {} [{}]'.format(self.id, len(objects), collection.id, key))

                batches += len(objects)

                connection = self.get_connection()
                try:
                    # Call API to create Import Objects
                    self.manager.logger.debug('dbapi_import object {} [{}]'.format(dumpjs(objects), key))
                    resp = self.manager.easydb_context.dbapi_import(
                        connection,
                        session,
                        collection.mask_name,
                        objects,
                        # collection id is not allowed for updates in DbapiImport::import_json
                        int(collection.id) if key == '_new' else None
                    )
                    connection.commit()

                    # merge file information with response for imported objects
                    if len(resp) == len(objects):
                        for i in range(len(objects)):

                            o_info = {
                                'asset': {},
                                'object': {}
                            }

                            if '__files' in objects[i]:
                                if len(objects[i]['__files']) > 0:
                                    for k in ['path', 'filename', 'size', 'eas_id']:
                                        if k in objects[i]['__files'][0]:
                                            o_info['asset'][k] = objects[i]['__files'][0][k]

                            if '_objecttype' in resp[i]:
                                ot = resp[i]['_objecttype']
                                o_info['object']['_objecttype'] = ot
                                if ot in resp[i] and '_id' in resp[i][ot]:
                                    o_info['object']['_id'] = resp[i][ot]['_id']

                                pool_id = get_json_value(
                                    objects[i], ot + '._pool.pool._id')
                                if pool_id is not None:
                                    o_info['object']['_pool_id'] = pool_id

                            for k in ['_created', '_system_object_id', '_mask', '_uuid']:
                                if k in resp[i]:
                                    o_info['object'][k] = resp[i][k]

                            hotfolder_objects.append(o_info)

                except Exception as e:
                    # error is somewhere in batch, have to fail whole batch
                    # as it's not possible to determine which object raised the error
                    for obj in objs:
                        self.handle_object_error(obj, e)
                    return False
                finally:
                    connection.close()

        if len(hotfolder_objects) > 0:
            info = {
                'collection_id': collection.id,
                'batch_size': batches,
                'objects': hotfolder_objects
            }

            self.logger.debug('info: {}'.format(dumpjs(hotfolder_objects)))

            # write an event for the success
            self.manager.log_event(json_info=info)

        return True

    def handle_file_error(self, file, e):
        for f in list(file.obj.files.values()):
            for v in f:
                write_error(v.get_error_file(), 'Could not upload file {0}: {1}'.format(
                    file.get_path(), self.parse_error(e)))

    def handle_removal_error(self, file, e):
        for f in list(file.obj.files.values()):
            for v in f:
                write_error(v.get_error_file(), 'Could not delete file {0}: {1}'.format(
                    file.get_path(), self.parse_error(e)))

    def handle_object_error(self, obj, e):
        for f in list(obj.files.values()):
            for v in f:
                write_error(v.get_error_file(), 'Could not import object {0}: {1}'.format(
                    obj.name, self.parse_error(e)))

    def handle_main_error(self, obj, e):
        for f in list(obj.files.values()):
            for v in f:
                write_error(v.get_error_file(), 'Unexpected error: {0}'.format(self.parse_error(e)))

    def parse_error(self, e):

        if isinstance(e, InternalError):
            error = 'internal error: {0}'.format(e)

        elif isinstance(e, EasydbException):

            code = e.get_code()

            if code == 'error.user.upload_limit_exceeded':
                error = 'upload limit exceeded for class \'{0}\' (limit = {1})'.format(e.get_parameter('class'), e.get_parameter('limit'))

            elif code == 'error.user.upload_type_not_allowed':
                error = 'file type {0}.{1} is not allowed'.format(e.get_parameter('class'), e.get_parameter('extension'))

            elif code == 'error.user.transition_reject':
                error = 'operation rejected: {0}'.format(e.get_parameter('message'))

            elif code == 'error.user.mapping_not_found':
                error = 'mapping \'{0}\' not found'.format(e.get_parameter('id'))

            elif code == 'error.user.insufficient_rights':
                extra = e.get_parameter('extra_info')
                if extra is not None and len(extra) > 0:
                    extra = ', {0}'.format(extra)
                error = 'insufficient rights: no right {0} {1}'.format(e.get_parameter('right'), extra)

            elif code == 'error.user.no_grantable_right':
                extra = e.get_parameter('extra_info')
                if extra is not None and len(extra) > 0:
                    extra = ', {0}'.format(extra)
                error = 'no grantable right: no right {0} {1}'.format(e.get_parameter('right'), extra)

            elif code == 'error.user.not_null_violation':
                if e.get_parameter('column'):
                    error = 'Column {} must not be Null. Update metadata or select adequate import profile in easydb to upload file'.format(
                        e.get_parameter('column'))
                else:
                    error = code

            elif code == 'error.user.not_null_violation_with_table':
                if e.get_parameter('column'):
                    if e.get_parameter('table'):
                        error = 'Column {} in table {} must not be Null. Update metadata or select adequate import profile in easydb to upload file'.format(
                            e.get_parameter('column'), e.get_parameter('table'))
                else:
                    error = code

            elif code is None:
                error = 'unknown easydb exception: {0}'.format(repr(e))
                print_traceback(e)

            else:
                error = '{}: {}'.format(code, dumpjs(e.parsed))
                print_traceback(e)

        else:
            error = 'unknown error: {0}'.format(repr(e))
            print_traceback(e)

        self.manager.logger.error('[{0}] {1}'.format(self.id, error))

        return error
