"""
informal interface for ai service classes
"""

import time
from auto_keyworder_modules.ai_services import ai_service_configuration
from auto_keyworder_modules import search, util, datamodel, easydb_api, eas, event


class AiServiceInterface:

    STATE_WAIT: int = 0
    STATE_SUCCESS: int = 1
    STATE_FAIL: int = 2

    config: ai_service_configuration.AiServiceConfiguration

    easydb_server: str
    easydb_token: str

    def __init__(
        self,
        config: ai_service_configuration.AiServiceConfiguration,
        easydb_server: str,
        easydb_token: str,
        logger=None,
    ) -> None:
        self.config = config
        self.easydb_server = easydb_server
        self.easydb_token = easydb_token
        self.logger = logger

    # ---------------------------------------------------

    def upload_image_to_service_api(
        self,
        asset_status: eas.AssetStatus,
    ) -> None: ...

    def get_ai_data_from_service_api(
        self,
        asset_status: eas.AssetStatus,
    ) -> None: ...

    def merge_ai_data_into_object(
        self,
        asset_status: eas.AssetStatus,
        # map: objecttype -> language -> unique text -> obj
        linked_object_cache: dict[str, dict[str, dict[str, dict]]],
    ) -> bool: ...

    def api_uses_base64(self) -> bool: ...

    def format_headers(self) -> dict[str]: ...

    # ---------------------------------------------------

    def update_main_object_info(self, obj: dict) -> None:
        ot = self.config.get_objecttype()
        version = util.get_json_value(obj, f'{ot}._version', True)
        if not isinstance(version, int):
            raise Exception(f'could not increase _version for object of type {ot}')
        if version < 1:
            raise Exception(
                f'could not increase _version for object of type {ot}: invalid version {version}'
            )

        version += 1
        obj[ot]['_version'] = version
        obj[ot][self.config.get_timestamp_field_info().name] = {
            'value': util.format_datetime(util.now()),
        }
        obj['_comment'] = (
            f'Updated by auto-keyworder plugin with information from {self.config.get_api_url()}'
        )

    def handle_linked_object(
        self,
        ai_data_to_use: dict,
        asset_status: eas.AssetStatus,
        language: str,
        linked_object_cache: dict[str, dict[str, dict[str, dict]]],
        field_info: datamodel.FieldInfo,
    ) -> dict:
        # get the specific value for the language
        value = ai_data_to_use.get(language)
        if not value:
            return None
        value = value.lower()

        # check if the linked object is in the cache
        if field_info.linked_ot in linked_object_cache:
            if language in linked_object_cache[field_info.linked_ot]:
                if value in linked_object_cache[field_info.linked_ot][language]:
                    util.log_debug(
                        self.logger,
                        f'found {field_info.linked_ot} with "{language}:{value}" in cache',
                    )
                    return linked_object_cache[field_info.linked_ot][language][value]
        else:
            linked_object_cache[field_info.linked_ot] = {language: {}}

        # search for the linked object
        query = search.build_query_by_field_values(
            objecttype=field_info.linked_ot,
            fieldname=field_info.name,
            keywords=[value],
            language=language,
        )
        result = search.do_search(
            easydb_server=self.easydb_server,
            easydb_token=self.easydb_token,
            query=query,
        )

        objects = result.get('objects')
        if not isinstance(objects, list):
            return None

        # iterate over the search results, if there are more than one return the first that matches exactly
        for obj in objects:
            valid_ids = []
            for id in util.get_json_value(obj, '_fields.id'):
                try:
                    id = int(str(id).strip())
                except:
                    continue
                valid_ids.append(id)
            if not isinstance(valid_ids, list) or len(valid_ids) == 0:
                continue

            keywords = util.get_json_value(obj, '_fields.keyword')
            if not isinstance(keywords, list) or len(keywords) == 0:
                continue

            if keywords[0].lower() == value:
                link = {
                    "_objecttype": field_info.linked_ot,
                    "_mask": '_all_fields',
                    field_info.linked_ot: {
                        '_id': valid_ids[0],
                    },
                }
                linked_object_cache[field_info.linked_ot][language][value] = link
                util.log_debug(
                    self.logger,
                    f'found linked object (objecttype {field_info.linked_ot}) in search, add to cache',
                )
                return link

        # insert a new linked object
        new_link = {
            '_comment': 'created by easydb-autokeyworder-plugin',
            '_objecttype': field_info.linked_ot,
            '_mask': field_info.linked_ot_mask,
            field_info.linked_ot: {
                '_version': 1,
                field_info.name: (
                    ai_data_to_use.get(language)
                    if not field_info.is_l10n()
                    else {
                        language: ai_data_to_use.get(language),
                    }
                ),
            },
        }

        statuscode, resp = easydb_api.easydb_post_objects(
            server=self.easydb_server,
            easydb_token=self.easydb_token,
            objecttype=field_info.linked_ot,
            objects=[new_link],
        )
        if statuscode != 200:
            util.log_warn(
                self.logger,
                f'easydb_post_objects failed with {statuscode}: {resp}',
            )

            # check if there is a unique constraint violation
            # this can happen if the search did not return a result for the keyword
            # for example when the record was added shortly before and was not indexed yet
            # then add a linked object with a lookup that is constructed from the unique value

            if statuscode == 400 and isinstance(resp, dict):
                if (
                    resp.get('code')
                    != 'error.user.unique_constraint_violation_with_column_and_value'
                ):
                    return None

                parameters = resp.get('parameters')
                if not isinstance(parameters, dict):
                    return None

                table_name = parameters.get('table_name')
                if not isinstance(table_name, str):
                    return None

                column_name = parameters.get('column_name')
                if not isinstance(column_name, str):
                    return None
                if not column_name.startswith(f'{table_name}.'):
                    return None
                column_name = column_name[len(table_name) + 1 :]

                column_value = parameters.get('column_value')
                if not isinstance(column_value, str):
                    return None

                util.log_debug(
                    self.logger,
                    f'parsed api error: linked object with {table_name}.{column_name}="{column_value}" already exists in database',
                )

                # build new linked object with lookup for unique column and value and it to the cache
                new_link_obj = {
                    '_mask': '_all_fields',
                    '_objecttype': table_name,
                    table_name: {
                        'lookup:_id': {
                            column_name: column_value,
                        }
                    },
                }
                linked_object_cache[table_name][language][column_value] = new_link_obj
                util.log_debug(
                    self.logger,
                    f'build link with lookup to {table_name}.{column_name} object for value "{column_value}" to cache',
                )

                return new_link_obj

            return None

        if isinstance(resp, list):
            for new_link_obj in resp:
                linked_object_cache[field_info.linked_ot][language][
                    value
                ] = new_link_obj
                util.log_debug(
                    self.logger,
                    f'created new link {field_info.linked_ot} object for value "{value}", add to cache',
                )

                linked_asset_status = asset_status.copy()
                linked_asset_status.easydb_obj = new_link_obj
                event.log_event(
                    easydb_server=self.easydb_server,
                    easydb_token=self.easydb_token,
                    event_data=event.format_object_change_event(
                        service_config=self.config,
                        asset_status=linked_asset_status,
                        asset_version=None,
                    ),
                )

                return new_link_obj

        return None

    @classmethod
    def format_base64_image_string(
        cls,
        base64_image: str,
        img_type: str,
    ) -> str:
        return f'data:image/{img_type};base64,{base64_image}'


# -----------------------------------------------------------
#
# main loop:
# - search for tagged objects
# - collect assets
# - send assets to ai service api
# - collect results
# - merge ai data into existing objects, insert linked objects if needed
# - upload updated objects
# - write events


def run(
    name: str,
    service: AiServiceInterface,
    search_chunk_size: int,
) -> None:

    # map: objecttype -> language -> unique text -> obj
    linked_object_cache: dict[str, dict[str, dict[str, dict]]] = {}

    # loop with pagination: use offset, limit in search
    offset = 0
    has_more = True

    while has_more:

        asset_status_map: dict[int, eas.AssetStatus] = {}

        # search for objects:
        # objecttype must match the configured objecttype
        # if there is a configured tagfilter, it must match
        # the objects must have an image in the configured asset field
        # the value in the configured timestamp field must be empty, or the minimum timestamp must have passed
        query = search.build_search_query(
            objecttype=service.config.get_objecttype(),
            tagfilter=service.config.get_tagfilter(),
            asset_field=service.config.get_asset_field_info().name,
            timestamp_field=service.config.get_timestamp_field_info().name,
            min_age_days=service.config.get_int('min_age_days', default=7),
            offset=offset,
            limit=search_chunk_size,
        )
        # util.log_debug(service.logger, util.dumpjs(query))

        # -----------------------------------------------------
        # search for objects that need to be updated

        result = search.do_search(
            easydb_server=service.easydb_server,
            easydb_token=service.easydb_token,
            query=query,
        )
        # util.log_debug(service.logger, util.dumpjs(result))

        size_search_results = search.map_search_results(
            result,
            service.config.get_objecttype(),
            asset_status_map,
        )
        if size_search_results == 0:
            util.log_debug(service.logger, f'[{name}] result empty, break search loop')
            has_more = False
            break

        util.log_debug(
            service.logger, f'[{name}] result: {size_search_results} unseen object(s)'
        )

        # -----------------------------------------------------
        # collect assets from objects

        # for each object, get the asset url or asset data (base64) from the eas and map it to the eas id
        asset_version = service.config.get_string(variable='', default='original')
        pending_objects, collected_errors = eas.map_assets(
            asset_map=asset_status_map,
            objecttype=service.config.get_objecttype(),
            asset_field=service.config.get_asset_field_info().name,
            asset_version=asset_version,
            use_base64=service.api_uses_base64(),
        )
        for error_message in collected_errors:
            # write events for failed asset uploades
            event.log_event(
                easydb_server=service.easydb_server,
                easydb_token=service.easydb_token,
                event_data={
                    'type': event.AUTO_KEYWORDER_ERROR,
                    'info': {
                        'error': 'could not get asset information',
                        'details': error_message,
                    },
                },
            )

        collected_errors = []
        assets_done = set()
        assets_failed = set()

        # for each asset data, upload the image url or base64 encoded image to the service and retrieve the data
        # and map the keywords to the eas id
        repititions = service.config.get_int(
            variable='request_status_repititions',
            default=3,
        )
        delay = service.config.get_int(
            variable='request_status_delay',
            default=5,
        )

        # repeat for a maximum number of tries, or until all are done or all failed
        tries = 0
        while tries < repititions:
            tries += 1
            util.log_debug(
                service.logger,
                f'[{name}] get keywords for objects (try {tries}/{repititions}): {pending_objects}',
            )

            for sys_id in pending_objects:
                if sys_id not in asset_status_map:
                    continue

                if asset_status_map[sys_id].finished:
                    assets_done.add(sys_id)
                    continue
                if asset_status_map[sys_id].failed:
                    assets_failed.add(sys_id)
                    util.log_warn(
                        service.logger,
                        f'[{name}] skip requesting of status for object #{sys_id}, already failed (reason: "{asset_status_map[sys_id].failure_reason}")',
                    )
                    continue

                if not asset_status_map[sys_id].uploaded:
                    service.upload_image_to_service_api(
                        asset_status=asset_status_map[sys_id],
                    )
                    if asset_status_map[sys_id].failed:
                        # failed
                        util.log_warn(
                            service.logger,
                            f'[{name}] uploading asset for #{sys_id} failed: {asset_status_map[sys_id].failure_reason}',
                        )
                        continue

                    asset_status_map[sys_id].uploaded = True

                service.get_ai_data_from_service_api(
                    asset_status=asset_status_map[sys_id],
                )

                if asset_status_map[sys_id].finished:
                    # successful
                    asset_status_map[sys_id].ai_data_added_to_object = False
                    util.log_debug(
                        service.logger,
                        f'[{name}] successfully got ai data for object #{sys_id}',
                    )
                    assets_done.add(sys_id)
                    continue

                if asset_status_map[sys_id].failed:
                    # failed
                    util.log_warn(
                        service.logger,
                        f'[{name}] getting ai data for object #{sys_id} failed: {asset_status_map[sys_id].failure_reason}',
                    )
                    assets_failed.add(sys_id)
                    continue

            if len(assets_done) > 0:
                util.log_debug(
                    service.logger,
                    f'[{name}] successfully got ai data for objects (try {tries }/{repititions}): {assets_done}',
                )
                for sys_id in assets_done:
                    if sys_id in pending_objects:
                        pending_objects.remove(sys_id)

            if len(assets_failed) > 0:
                util.log_debug(
                    service.logger,
                    f'[{name}] failed to get ai data for objects (try {tries }/{repititions}): {assets_failed}',
                )
                for sys_id in assets_failed:
                    if sys_id in pending_objects:
                        pending_objects.remove(sys_id)

            if len(pending_objects) == 0:
                # all finished
                break

            if tries >= repititions:
                break

            # more to do, wait until next repition starts
            time.sleep(delay)

        # if there are still any pending assets left, write error events
        if len(pending_objects) > 0:
            util.log_warn(
                service.logger,
                f'[{name}] still pending objects after {tries} tries: {pending_objects}',
            )

        # there are still pending objects but instead of waiting longer for a response, give up and write events
        for sys_id in pending_objects:
            obj_id = util.get_json_value(
                asset_status_map[sys_id].easydb_obj,
                f'{service.config.get_objecttype()}._id',
            )
            event.log_event(
                easydb_server=service.easydb_server,
                easydb_token=service.easydb_token,
                event_data=event.format_ai_service_failed_event(
                    service_config=service.config,
                    error_message=f'did not get a result from the ai service api, giving up after {tries} tries',
                    asset_status=asset_status_map[sys_id],
                    asset_version=asset_version,
                ),
            )

        # for all collected ai data, search the objects with the eas ids and update the objects
        objects_to_update = []
        update_event_infos = []

        for sys_id in asset_status_map:
            if asset_status_map[sys_id].failed:
                # write an error event for failed tagging
                obj_id = util.get_json_value(
                    asset_status_map[sys_id].easydb_obj,
                    f'{service.config.get_objecttype()}._id',
                )
                event.log_event(
                    easydb_server=service.easydb_server,
                    easydb_token=service.easydb_token,
                    event_data=event.format_ai_service_failed_event(
                        service_config=service.config,
                        error_message=asset_status_map[sys_id].failure_reason,
                        asset_status=asset_status_map[sys_id],
                        asset_version=asset_version,
                    ),
                )
                continue

            if asset_status_map[sys_id].ai_data_added_to_object:
                continue
            if asset_status_map[sys_id].record_updated:
                continue
            if not asset_status_map[sys_id].ai_data:
                continue

            util.log_debug(
                service.logger,
                f'[{name}] object #{sys_id}: merge ai data',
            )

            if not service.merge_ai_data_into_object(
                asset_status=asset_status_map[sys_id],
                linked_object_cache=linked_object_cache,
            ):
                util.log_warn(
                    service.logger,
                    f'[{name}] merging ai data into object #{sys_id} failed',
                )
                asset_status_map[sys_id].ai_data_added = False
                continue

            asset_status_map[sys_id].ai_data_added = True

        # finalize updated objects and generate event info
        for sys_id in asset_status_map:
            if asset_status_map[sys_id].ai_data_added_to_object:
                continue
            if asset_status_map[sys_id].record_updated:
                continue
            if asset_status_map[sys_id].failed:
                continue
            if not asset_status_map[sys_id].ai_data:
                continue

            objects_to_update.append(asset_status_map[sys_id].easydb_obj)

            update_event_infos.append(
                event.format_object_change_event(
                    service_config=service.config,
                    asset_status=asset_status_map[sys_id],
                    asset_version=asset_version,
                ),
            )

        # post the list of objects to api/v1/db/<objecttype>
        # if update was successful, log events for each object
        if len(objects_to_update) > 0:
            # util.log_debug(
            #     service.logger,
            #     f'[{name}] update objects: {util.dumpjs(objects_to_update)}',
            # )
            util.log_debug(
                service.logger,
                f'[{name}] update {len(objects_to_update)} objects of type {service.config.get_objecttype()}',
            )

            statuscode, resp = easydb_api.easydb_post_objects(
                server=service.easydb_server,
                easydb_token=service.easydb_token,
                objecttype=service.config.get_objecttype(),
                objects=objects_to_update,
            )
            if statuscode == 200:
                util.log_debug(
                    service.logger,
                    f'[{name}] successfully updated {len(objects_to_update)} objects',
                )
                event.log_object_events(
                    easydb_server=service.easydb_server,
                    easydb_token=service.easydb_token,
                    event_infos=update_event_infos,
                )
            else:
                util.log_debug(
                    service.logger, f'[{name}] ERROR: {statuscode}: {util.dumpjs(resp)}'
                )

        if size_search_results < search_chunk_size:
            util.log_debug(
                service.logger,
                f'[{name}] size {size_search_results} < {search_chunk_size} => break search loop',
            )
            has_more = False

        offset += search_chunk_size
