# =================================================================
#
# Authors: Tom Kralidis <tomkralidis@gmail.com>
#          Ricardo Garcia Silva <ricardo.garcia.silva@geobeyond.it>
#          Francesco Martinelli <francesco.martinelli@ingv.it>
#
# Copyright (c) 2024 Tom Kralidis
#           (c) 2023 Ricardo Garcia Silva
#           (c) 2026 Francesco Martinelli
#
# Permission is hereby granted, free of charge, to any person
# obtaining a copy of this software and associated documentation
# files (the "Software"), to deal in the Software without
# restriction, including without limitation the rights to use,
# copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the
# Software is furnished to do so, subject to the following
# conditions:
#
# The above copyright notice and this permission notice shall be
# included in all copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
# OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
# HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
# WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
# OTHER DEALINGS IN THE SOFTWARE.
#
# =================================================================

import collections
import json
import logging
from multiprocessing import dummy
from pathlib import Path
from typing import Any, Dict, Tuple, Optional, OrderedDict
import uuid

import requests

from pygeoapi.plugin import load_plugin
from pygeoapi.process.base import (
    BaseProcessor,
    JobNotFoundError,
    JobResultNotFoundError,
    ProcessorExecuteError,
    UnknownProcessError,
)
from pygeoapi.util import (
    get_current_datetime,
    is_request_allowed,
    JobStatus,
    ProcessExecutionMode,
    RequestedProcessExecutionMode,
    RequestedResponse,
    Subscriber
)

LOGGER = logging.getLogger(__name__)


class BaseManager:
    """generic Manager ABC"""
    processes: OrderedDict[str, Dict]

    def __init__(self, manager_def: dict):
        """
        Initialize object

        :param manager_def: manager definition

        :returns: `pygeoapi.process.manager.base.BaseManager`
        """

        self.name = manager_def['name']
        self.is_async = False
        self.supports_subscribing = False
        self.connection = manager_def.get('connection')
        self.output_dir = manager_def.get('output_dir')

        if self.output_dir is not None:
            self.output_dir = Path(self.output_dir)

        # Note: There are two different things named OrderedDict here - one
        # is coming from typing.OrderedDict (type annotation), the other is
        # coming from collections.OrderedDict (actual type we want to use here)
        # - this will not be needed anymore when pygeoapi moves to requiring
        # Python 3.9 as the minimum supported Python version
        self.processes = collections.OrderedDict()
        for id_, process_conf in manager_def.get('processes', {}).items():
            self.processes[id_] = dict(process_conf)

    def get_processor(self, process_id: str) -> BaseProcessor:
        """Instantiate a processor.

        :param process_id: Identifier of the process

        :raises UnknownProcessError: if the processor cannot be created
        :returns: instance of the processor
        """

        try:
            process_conf = self.processes[process_id]
        except KeyError as err:
            raise UnknownProcessError('Invalid process identifier') from err
        else:
            pp = load_plugin('process', process_conf['processor'])
            pp.allow_internal_requests = process_conf.get(
                'allow_internal_requests', False)

            return pp

    def get_jobs(self,
                 status: JobStatus = None,
                 limit: Optional[int] = None,
                 offset: Optional[int] = None
                 ) -> dict:
        """
        Get process jobs, optionally filtered by status

        :param status: job status (accepted, running, successful,
                       failed, results) (default is all)
        :param limit: number of jobs to return
        :param offset: pagination offset

        :returns: dict of list of jobs (identifier, status, process identifier)
                  and numberMatched
        """

        raise NotImplementedError()

    def add_job(self, job_metadata: dict) -> str:
        """
        Add a job

        :param job_metadata: `dict` of job metadata

        :returns: `str` added job identifier
        """

        raise NotImplementedError()

    def update_job(self, job_id: str, update_dict: dict) -> bool:
        """
        Updates a job

        :param job_id: job identifier
        :param update_dict: `dict` of property updates

        :returns: `bool` of status result
        """

        raise NotImplementedError()

    def get_job(self, job_id: str) -> dict:
        """
        Get a job (!)

        :param job_id: job identifier

        :raises JobNotFoundError: if the job_id does not correspond to a
                                  known job
        :returns: `dict` of job result
        """

        raise JobNotFoundError()

    def get_job_result(self, job_id: str) -> Tuple[str, Any]:
        """
        Returns the actual output from a completed process

        :param job_id: job identifier

        :raises JobNotFoundError: if the job_id does not correspond to a
                                  known job
        :raises JobResultNotFoundError: if the job-related result cannot
                                         be returned
        :returns: `tuple` of mimetype and raw output
        """

        raise JobResultNotFoundError()

    def delete_job(self, job_id: str) -> bool:
        """
        Deletes a job and associated results/outputs

        :param job_id: job identifier

        :raises JobNotFoundError: if the job_id does not correspond to a
                                   known job
        :returns: `bool` of status result
        """

        raise JobNotFoundError()

    def _execute_handler_async(self, p: BaseProcessor, job_id: str,
                               data_dict: dict,
                               requested_outputs: Optional[dict] = None,
                               subscriber: Optional[Subscriber] = None,
                               requested_response: Optional[RequestedResponse] = RequestedResponse.raw.value  # noqa
                               ) -> Tuple[str, None, JobStatus]:
        """
        This private execution handler executes a process in a background
        thread using `multiprocessing.dummy`

        https://docs.python.org/3/library/multiprocessing.html#module-multiprocessing.dummy  # noqa

        :param p: `pygeoapi.process` object
        :param job_id: job identifier
        :param data_dict: `dict` of data parameters
        :param requested_outputs: `dict` optionally specifying the subset of
                                  required outputs - defaults to all outputs.
                                  The value of any key may be an object and
                                  include the property `transmissionMode`
                                  (defaults to `value`)
                                  Note: 'optional' is for backward
                                  compatibility.
        :param subscriber: optional `Subscriber` specifying callback URLs
        :param requested_response: `RequestedResponse` optionally specifying
                                   raw or document (default is `raw`)

        :returns: tuple of None (i.e. initial response payload)
                  and JobStatus.accepted (i.e. initial job status)
        """

        args = (p, job_id, data_dict, requested_outputs, subscriber,
                requested_response)

        _process = dummy.Process(target=self._execute_handler_sync, args=args)
        _process.start()

        return 'application/json', None, JobStatus.accepted

    def _execute_handler_sync(self, p: BaseProcessor, job_id: str,
                              data_dict: dict,
                              requested_outputs: Optional[dict] = None,
                              subscriber: Optional[Subscriber] = None,
                              requested_response: Optional[RequestedResponse] = RequestedResponse.raw.value  # noqa
                              ) -> Tuple[str, Any, JobStatus]:
        """
        Synchronous execution handler

        If the manager has defined `output_dir`, then the result
        will be written to disk
        output store. There is no clean-up of old process outputs.

        :param p: `pygeoapi.process` object
        :param job_id: job identifier
        :param data_dict: `dict` of data parameters
        :param requested_outputs: `dict` optionally specifying the subset of
                                  required outputs - defaults to all outputs.
                                  The value of any key may be an object and
                                  include the property `transmissionMode`
                                  (defaults to `value`)
                                  Note: 'optional' is for backward
                                  compatibility.
        :param subscriber: optional `Subscriber` specifying callback URLs
        :param requested_response: `RequestedResponse` optionally specifying
                                   raw or document (default is `raw`)

        :returns: tuple of MIME type, response payload and status
        """

        extra_execute_parameters = {}

        # only pass requested_outputs if supported,
        # otherwise this breaks existing processes
        if p.supports_outputs:
            extra_execute_parameters['outputs'] = requested_outputs

        self._send_in_progress_notification(subscriber)

        try:
            if self.output_dir is not None:
                filename = f"{p.metadata['id']}-{job_id}"
                job_filename = self.output_dir / filename
            else:
                job_filename = None

            current_status = JobStatus.running
            jfmt, outputs = p.execute(data_dict, **extra_execute_parameters)

            if isinstance(outputs, bytes):
                outputs = outputs.decode('utf-8')

            if requested_response == RequestedResponse.document.value:
                outputs = {
                    'outputs': [outputs]
                }

            self.update_job(job_id, {
                'updated': get_current_datetime(),
                'status': current_status.value,
                'message': 'Writing job output',
                'progress': 95
            })

            if self.output_dir is not None:
                LOGGER.debug(f'writing output to {job_filename}')
                if isinstance(outputs, (dict, list)):
                    mode = 'w'
                    data = json.dumps(outputs, sort_keys=True, indent=4)
                    encoding = 'utf-8'
                elif isinstance(outputs, bytes):
                    mode = 'wb'
                    data = outputs
                    encoding = None
                elif isinstance(outputs, str):
                    mode = 'w'
                    data = outputs
                    encoding = None
                with job_filename.open(mode=mode, encoding=encoding) as fh:
                    fh.write(data)

            current_status = JobStatus.successful

            job_update_metadata = {
                'finished': get_current_datetime(),
                'updated': get_current_datetime(),
                'status': current_status.value,
                'location': str(job_filename),
                'mimetype': jfmt,
                'message': 'Job complete',
                'progress': 100
            }

            self.update_job(job_id, job_update_metadata)
            self._send_success_notification(subscriber, outputs=outputs)

        except Exception as err:
            # TODO assess correct exception type and description to help users
            # NOTE, the /results endpoint should return the error HTTP status
            # for jobs that failed, the specification says that failing jobs
            # must still be able to be retrieved with their error message
            # intact, and the correct HTTP error status at the /results
            # endpoint, even if the /result endpoint correctly returns the
            # failure information (i.e. what one might assume is a 200
            # response).

            current_status = JobStatus.failed
            code = 'InvalidParameterValue'
            outputs = {
                'type': code,
                'code': code,
                'description': f'Error executing process: {err}'
            }
            LOGGER.exception(err)
            job_metadata = {
                'finished': get_current_datetime(),
                'updated': get_current_datetime(),
                'status': current_status.value,
                'location': None,
                'mimetype': 'application/octet-stream',
                'message': f'{code}: {outputs["description"]}'
            }

            jfmt = 'application/json'

            self.update_job(job_id, job_metadata)
            self._send_failed_notification(subscriber)

        return jfmt, outputs, current_status

    def execute_process(
            self,
            process_id: str,
            data_dict: dict,
            execution_mode: Optional[RequestedProcessExecutionMode] = None,
            requested_outputs: Optional[dict] = None,
            subscriber: Optional[Subscriber] = None,
            requested_response: Optional[RequestedResponse] = RequestedResponse.raw.value  # noqa
    ) -> Tuple[str, Any, JobStatus, Optional[Dict[str, str]]]:
        """
        Default process execution handler

        :param process_id: process identifier
        :param data_dict: `dict` of data parameters
        :param execution_mode: `str` optionally specifying sync or async
                               processing.
        :param requested_outputs: `dict` optionally specifying the subset of
                                  required outputs - defaults to all outputs.
                                  The value of any key may be an object and
                                  include the property `transmissionMode`
                                  (default is `value`)
                                  Note: 'optional' is for backward
                                  compatibility.
        :param subscriber: `Subscriber` optionally specifying callback urls
        :param requested_response: `RequestedResponse` optionally specifying
                                   raw or document (default is `raw`)


        :raises UnknownProcessError: if the input process_id does not
                                     correspond to a known process
        :returns: tuple of job_id, MIME type, response payload, status and
                  optionally additional HTTP headers to include in the final
                  response
        """

        job_id = str(uuid.uuid1())
        self.processor = self.get_processor(process_id)
        self.processor.set_job_id(job_id)
        extra_execute_handler_parameters = {
            'requested_response': requested_response
        }

        job_control_options = self.processor.metadata.get(
            'jobControlOptions', [])

        if execution_mode == RequestedProcessExecutionMode.respond_async:
            # client wants async - do we support it?
            process_supports_async = (
                ProcessExecutionMode.async_execute.value in job_control_options
                )
            if self.is_async and process_supports_async:
                LOGGER.debug('Asynchronous execution')
                handler = self._execute_handler_async
                response_headers = {
                    'Preference-Applied': (
                        RequestedProcessExecutionMode.respond_async.value)
                }
            else:
                LOGGER.debug('Synchronous execution')
                handler = self._execute_handler_sync
                response_headers = {
                    'Preference-Applied': (
                        RequestedProcessExecutionMode.wait.value)
                }
        else:  # client has no preference or clients wants sync
            # do we support sync?
            process_supports_sync = (
                ProcessExecutionMode.sync_execute.value in job_control_options
                )
            if not process_supports_sync:
                LOGGER.debug('Asynchronous execution')
                handler = self._execute_handler_async
                response_headers = {
                    'Preference-Applied': (
                        RequestedProcessExecutionMode.respond_async.value)
                }
            else:
                # according to OAPI - Processes spec we ought to
                # respond with sync
                LOGGER.debug('Synchronous execution')
                handler = self._execute_handler_sync
                if execution_mode == RequestedProcessExecutionMode.wait:
                    response_headers = None
                else:
                    response_headers = {
                        'Preference-Applied': (
                            RequestedProcessExecutionMode.wait.value)
                    }

        # Add Job before returning any response.
        current_status = JobStatus.accepted
        job_metadata = {
            'type': 'process',
            'identifier': job_id,
            'process_id': process_id,
            'created': get_current_datetime(),
            'started': get_current_datetime(),
            'updated': get_current_datetime(),
            'finished': None,
            'status': current_status.value,
            'location': None,
            'mimetype': 'application/octet-stream',
            'message': 'Job accepted and ready for execution',
            'progress': 5
        }
        self.add_job(job_metadata)

        # only pass subscriber if supported, otherwise this breaks
        # existing managers
        if self.supports_subscribing:
            extra_execute_handler_parameters['subscriber'] = subscriber

        # TODO: handler's response could also be allowed to include more HTTP
        # headers
        mime_type, outputs, status = handler(
            self.processor,
            job_id,
            data_dict,
            requested_outputs,
            **extra_execute_handler_parameters)

        return job_id, mime_type, outputs, status, response_headers

    def _send_in_progress_notification(self, subscriber: Optional[Subscriber]):
        if subscriber and subscriber.in_progress_uri:
            self.__do_subscriber_request(subscriber.in_progress_uri)

    def _send_success_notification(
            self, subscriber: Optional[Subscriber], outputs: Any
    ):
        if subscriber and subscriber.success_uri:
            self.__do_subscriber_request(subscriber.success_uri, outputs)

    def _send_failed_notification(self, subscriber: Optional[Subscriber]):
        if subscriber and subscriber.failed_uri:
            self.__do_subscriber_request(subscriber.failed_uri)

    def __do_subscriber_request(self, url: str, data: dict = {}) -> None:
        """
        Helper function to execute a subscriber URL via HTTP POST

        :param url: `str` of URL
        :param data: `dict` of request payload

        :returns: `None`
        """

        if not is_request_allowed(url, self.processor.allow_internal_requests):
            msg = 'URL not allowed'
            LOGGER.error(f'{msg}: {url}')
            raise ProcessorExecuteError(msg)

        response = requests.post(url, json=data)
        LOGGER.debug(
            f'Response: {response.status_code}'
        )

    def __repr__(self):
        return f'<BaseManager> {self.name}'


def get_manager(config: Dict) -> BaseManager:
    """Instantiate process manager from the supplied configuration.

    :param config: pygeoapi configuration

    :returns: The pygeoapi process manager object
    """
    manager_conf = config.get('server', {}).get(
        'manager',
        {
            'name': 'Dummy',
            'connection': None,
            'output_dir': None
        }
    )
    processes_conf = {}
    for id_, resource_conf in config.get('resources', {}).items():
        if resource_conf.get('type') == 'process':
            processes_conf[id_] = resource_conf
    manager_conf['processes'] = processes_conf
    if manager_conf.get('name') == 'Dummy':
        LOGGER.info('Starting dummy manager')
    return load_plugin('process_manager', manager_conf)
