import asyncio
from datetime import datetime
import json
import logging
from tukio import Engine, TaskRegistry, get_broker, EXEC_TOPIC
from tukio.workflow import (
    TemplateGraphError, Workflow, WorkflowTemplate, WorkflowExecState
)
from uuid import uuid4

from nyuki import Nyuki, resource, Response
from nyuki.bus import reporting
from .storage import MongoStorage, DuplicateTemplateError
from .tasks import *
from .validation import validate, TemplateError


log = logging.getLogger(__name__)


class BadRequestError(Exception):
    pass


class ConflictError(Exception):
    pass


class WorkflowNyuki(Nyuki):

    """
    Generic workflow nyuki allowing data storage and manipulation
    of tukio's workflows.
    https://github.com/optiflows/tukio
    """

    CONF_SCHEMA = {
        'type': 'object',
        'required': ['mongo'],
        'properties': {
            'mongo': {
                'type': 'object',
                'required': ['host'],
                'properties': {
                    'host': {'type': 'string', 'minLength': 1},
                    'database': {'type': 'string', 'minLength': 1}
                }
            },
            'topics': {
                'type': 'array',
                'items': {'type': 'string', 'minLength': 1}
            }
        }
    }

    def __init__(self, **kwargs):
        super().__init__(**kwargs)
        self.register_schema(self.CONF_SCHEMA)
        self.migrate_config()
        self.engine = None
        self.storage = None

        self.AVAILABLE_TASKS = {}
        for name, value in TaskRegistry.all().items():
            self.AVAILABLE_TASKS[name] = getattr(value[0], 'SCHEMA', {})

    @property
    def mongo_config(self):
        return self.config['mongo']

    @property
    def topics(self):
        return self.config.get('topics', [])

    async def setup(self):
        self.engine = Engine(loop=self.loop)
        await self.reload_from_storage()
        for topic in self.topics:
            asyncio.ensure_future(self.bus.subscribe(
                self.bus.publish_topic(topic), self.workflow_event
            ))

    async def reload(self):
        await self.reload_from_storage()

    async def teardown(self):
        if self.engine:
            await self.engine.stop()

    async def workflow_event(self, efrom, data):
        await self.engine.data_received(data, efrom)

    async def reload_from_storage(self):
        """
        Check mongo, retrieve and load all templates
        """
        self.storage = MongoStorage(**self.mongo_config)

        templates = await self.storage.templates.get_all(
            latest=True,
            with_metadata=False
        )

        for template in templates:
            try:
                await self.engine.load(WorkflowTemplate.from_dict(template))
            except Exception as exc:
                # Means a bad workflow is in database, report it
                reporting.exception(exc)

    async def update_draft(self, template, from_request=None):
        """
        Helper to insert/update a draft
        """
        tmpl_dict = template.as_dict()

        # Auto-increment version, draft only
        last_version = await self.storage.templates.get_last_version(template.uid)
        tmpl_dict['version'] = last_version + 1
        tmpl_dict['draft'] = True

        # Store task extra info (ie. title)
        if from_request is not None:
            rqst_tasks = from_request.get('tasks', [])
            tmpl_tasks = tmpl_dict['tasks']
            for src in rqst_tasks:
                match = list(filter(lambda t: t['id'] == src['id'], tmpl_tasks))
                if match:
                    match[0].update({'title': src.get('title')})

        try:
            await self.storage.templates.insert_draft(tmpl_dict)
        except DuplicateTemplateError as exc:
            raise ConflictError('Template already exists for this version') from exc

        return tmpl_dict

    def errors_from_validation(self, template):
        """
        Validate and return the list of errors if any
        """
        errors = None
        try:
            validate(template)
        except TemplateError as err:
            errors = err.as_dict()
        return errors

    @resource('/workflow/templates', version='v1')
    class Templates:

        async def get(self, request):
            """
            Return available workflows' DAGs
            """
            return Response(await self.storage.templates.get_all(
                latest=request.GET.get('latest') in ['true', 'True'],
                draft=request.GET.get('draft') in ['true', 'True'],
            ))

        async def put(self, request):
            """
            Create a workflow DAG from JSON
            """
            request = await request.json()

            if 'id' in request:
                if await self.storage.templates.get(request['id'], draft=True):
                    return Response(status=409, body={
                        'error': 'draft already exists'
                    })

            try:
                template = WorkflowTemplate.from_dict(request)
            except TemplateGraphError as exc:
                return Response(status=400, body={
                    'error': str(exc)
                })

            metadata = await self.storage.templates.get_metadata(template.uid)
            if not metadata:
                if 'title' not in request:
                    return Response(status=400, body={
                        'error': "workflow 'title' key is mandatory"
                    })

                metadata = {
                    'id': template.uid,
                    'title': request['title'],
                    'description': request.get('description', ''),
                    'tags': request.get('tags', [])
                }

                await self.storage.templates.insert_metadata(metadata)
            else:
                metadata = metadata[0]

            try:
                tmpl_dict = await self.update_draft(template, request)
            except ConflictError as exc:
                return Response(status=409, body={
                    'error': exc
                })

            return Response({
                **tmpl_dict,
                **metadata,
                'errors': self.errors_from_validation(template)
            })

    @resource('/workflow/templates/{tid}', version='v1')
    class Template:

        async def get(self, request, tid):
            """
            Return the latest version of the template
            """
            tmpl = await self.storage.templates.get(tid)
            if not tmpl:
                return Response(status=404)

            return Response(tmpl)

        async def put(self, request, tid):
            """
            Create a new draft for this template id
            """
            versions = await self.storage.templates.get(tid)
            if not versions:
                return Response(status=404)

            for v in versions:
                if v['draft'] is True:
                    return Response(status=409, body={
                        'error': 'This draft already exists'
                    })

            request = await request.json()

            try:
                # Set template ID from url
                template = WorkflowTemplate.from_dict({**request, 'id': tid})
            except TemplateGraphError as exc:
                return Response(status=400, body={
                    'error': str(exc)
                })

            try:
                tmpl_dict = await self.update_draft(template, request)
            except ConflictError as exc:
                return Response(status=409, body={
                    'error': exc
                })

            metadata = await self.storage.templates.get_metadata(template.uid)
            metadata = metadata[0]

            return Response({
                **tmpl_dict,
                **metadata,
                'errors': self.errors_from_validation(template)
            })

        async def patch(self, request, tid):
            """
            Modify the template's metadata
            """
            tmpl = await self.storage.templates.get(tid)
            if not tmpl:
                return Response(status=404)

            request = await request.json()

            # Add ID, request dict cleaned in storage
            metadata = await self.storage.templates.insert_metadata({
                **request,
                'id': tid
            })

            return Response(metadata)

        async def delete(self, request, tid):
            """
            Delete the template
            """
            tmpl = await self.storage.templates.get(tid)
            if not tmpl:
                return Response(status=404)

            await self.storage.templates.delete(tid)

            try:
                await self.engine.unload(tid)
            except KeyError as exc:
                log.debug(exc)

            return Response(tmpl)

    @resource('/workflow/templates/{tid}/{version:\d+}', version='v1')
    class TemplateVersion:

        async def get(self, request, tid, version):
            """
            Return the template's given version
            """
            tmpl = await self.storage.templates.get(tid, version, False)
            if not tmpl:
                return Response(status=404)

            return Response(tmpl)

        async def delete(self, request, tid, version):
            """
            Delete a template with given version
            """
            tmpl = await self.storage.templates.get(tid)
            if not tmpl:
                return Response(status=404)

            await self.storage.templates.delete(tid, version)
            return Response(tmpl[0])

    @resource('/workflow/templates/{tid}/draft', version='v1')
    class TemplateDraft:

        async def get(self, request, tid):
            """
            Return the template's draft, if any
            """
            tmpl = await self.storage.templates.get(tid, draft=True)
            if not tmpl:
                return Response(status=404)

            return Response(tmpl[0])

        async def post(self, request, tid):
            """
            Publish a draft into production
            """
            tmpl = await self.storage.templates.get(tid, draft=True)
            if not tmpl:
                return Response(status=404)

            draft = {
                **tmpl[0],
                'draft': False
            }

            try:
                # Set template ID from url
                template = WorkflowTemplate.from_dict({**draft, 'draft': False})
            except TemplateGraphError as exc:
                return Response(status=400, body={
                    'error': str(exc)
                })

            errors = self.errors_from_validation(template)
            if errors is not None:
                return Response(status=400, body=errors)

            await self.engine.load(template)
            # Update draft into a new template
            await self.storage.templates.publish_draft(tid)
            return Response(draft)

        async def patch(self, request, tid):
            """
            Modify the template's draft
            """
            tmpl = await self.storage.templates.get(tid, draft=True)
            if not tmpl:
                return Response(status=404)

            request = await request.json()

            try:
                # Set template ID from url
                template = WorkflowTemplate.from_dict({**request, 'id': tid})
            except TemplateGraphError as exc:
                return Response(status=400, body={
                    'error': str(exc)
                })

            try:
                tmpl_dict = await self.update_draft(template, request)
            except ConflictError as exc:
                return Response(status=409, body={
                    'error': str(exc)
                })

            metadata = await self.storage.templates.get_metadata(template.uid)
            metadata = metadata[0]

            return Response({
                **tmpl_dict,
                **metadata,
                'errors': self.errors_from_validation(template)
            })

        async def delete(self, request, tid):
            """
            Delete the template's draft
            """
            tmpl = await self.storage.templates.get(tid, draft=True)
            if not tmpl:
                return Response(status=404)

            await self.storage.templates.delete(tid, draft=True)
            return Response(tmpl[0])

    def serialize_wflow_exec(self, obj):
        """
        JSON default serializer for datetime/isoformat
        """
        if isinstance(obj, datetime):
            return obj.isoformat()
        if isinstance(obj, Workflow):
            return obj.report()
        raise TypeError('obj not serializable: {}'.format(obj))

    def register_async_handler(self, async_channel, wflow):
        broker = get_broker()
        async def exec_handler(event):
            # Pass if event does not concern this workflow execution
            if event.source.workflow_exec_id != wflow.uid:
                return
            # Publish the event's data
            # TODO: Beware of unserializable objects
            asyncio.ensure_future(self.bus.publish(
                event.data, topic=async_channel
            ))
            # If the workflow is in a final state, unregister
            if event.data['type'] in [
                WorkflowExecState.end.value,
                WorkflowExecState.error.value
            ]:
                broker.unregister(exec_handler, topic=EXEC_TOPIC)
        broker.register(exec_handler, topic=EXEC_TOPIC)

    @resource('/workflow/instances', version='v1')
    class Workflows:

        async def get(self, request):
            """
            Return workflow instances
            """
            return Response(
                json.dumps(
                    [wflow for wflow in self.engine.instances.values()],
                    default=self.serialize_wflow_exec),
                content_type='application/json'
            )

        async def put(self, request):
            """
            Start a workflow from payload:
            {
                "id": "template_id",
                "draft": true/false
            }
            """
            async_channel = request.headers.get('X-Surycat-Async-Channel')
            request = await request.json()

            if 'id' not in request:
                return Response(status=400, body={
                    'error': "Template's ID key 'id' is mandatory"
                })

            draft = request.get('draft', False)
            templates = await self.storage.templates.get(
                request['id'],
                draft=draft,
                with_metadata=False
            )

            if not templates:
                return Response(status=404, body={
                    'error': 'Could not find a suitable template to run'
                })

            wf_tmpl = WorkflowTemplate.from_dict(templates[0])
            data = request.get('inputs', {})
            if draft:
                wflow = await self.engine.run_once(wf_tmpl, data)
            else:
                wflow = await self.engine.trigger(wf_tmpl.uid, data)

            if wflow is None:
                return Response(status=400, body={
                    'error': 'Could not start any workflow from this template'
                })

            # Handle async workflow exec updates
            if async_channel is not None:
                self.register_async_handler(async_channel, wflow)

            return Response(
                json.dumps(wflow, default=self.serialize_wflow_exec),
                content_type='application/json'
            )

    @resource('/workflow/instances/{iid}', version='v1')
    class Workflow:

        async def get(self, request, iid):
            """
            Return a workflow instance
            """
            wflow = self.engine.instances.get(iid)
            if not wflow:
                return Response(status=404)

            return Response(
                json.dumps(wflow, default=self.serialize_wflow_exec),
                content_type='application/json'
            )

        async def post(self, request, iid):
            """
            Operate on a workflow instance
            """

    @resource('/workflow/tasks', version='v1')
    class Tasks:

        async def get(self, request):
            """
            Return the available tasks
            """
            return Response(self.AVAILABLE_TASKS)

    @resource('/workflow/topics', version='v1')
    class Topics:

        async def get(self, request):
            """
            Return the list of available topics
            """
            return Response(self.topics)

    @resource('/workflow/regexes', version='v1')
    class Regexes:

        async def get(self, request):
            """
            Return the list of all regexes
            """
            return Response(await self.storage.regexes.get_all())

        async def put(self, request):
            """
            Insert a new rule
            """
            request = await request.json()

            if 'name' not in request:
                return Response(status=400, body={
                    'error': "missing parameter 'name'"
                })

            data = {
                'id': str(uuid4()),
                'name': request['name'],
                'config': request.get('config', {})
            }

            await self.storage.regexes.insert(data)
            return Response(data)

        async def delete(self, request):
            """
            Delete all regexes and return the list
            """
            rules = await self.storage.regexes.get_all()
            await self.storage.regexes.delete()
            return Response(rules)

    @resource('/workflow/regexes/{rule_id}', version='v1')
    class Regex:

        async def get(self, request, rule_id):
            """
            Return the rule for id `rule_id`
            """
            rule = await self.storage.regexes.get(rule_id)
            if not rule:
                return Response(status=404)
            return Response(rule)

        async def patch(self, request, rule_id):
            """
            Modify an existing regex rule
            """
            rule = await self.storage.regexes.get(rule_id)
            if not rule:
                return Response(status=404)

            request = await request.json()

            data = {
                'id': rule_id,
                'name': request.get('name', rule['name']),
                'config': request.get('config', rule['config'])
            }

            await self.storage.regexes.insert(data)
            return Response(data)

        async def delete(self, request, rule_id):
            """
            Delete the rule with id `rule_id`
            """
            rule = await self.storage.regexes.get(rule_id)
            if not rule:
                return Response(status=404)

            await self.storage.regexes.delete(rule_id=rule_id)
            return Response(rule)

    @resource('/workflow/lookups', version='v1')
    class Lookups:

        async def get(self, request):
            """
            Return the list of all lookups
            """
            return Response(await self.storage.lookups.get_all())

        async def put(self, request):
            """
            Insert a new rule
            """
            request = await request.json()

            if 'name' not in request:
                return Response(status=400, body={
                    'error': "missing parameter 'name'"
                })

            data = {
                'id': str(uuid4()),
                'name': request['name'],
                'config': request.get('config', {})
            }

            await self.storage.lookups.insert(data)
            return Response(data)

        async def delete(self, request):
            """
            Delete all lookups and return the list
            """
            rules = await self.storage.lookups.get_all()
            await self.storage.lookups.delete()
            return Response(rules)

    @resource('/workflow/lookups/{rule_id}', version='v1')
    class Lookup:

        async def get(self, request, rule_id):
            """
            Return the rule for id `rule_id`
            """
            rule = await self.storage.lookups.get(rule_id)
            if not rule:
                return Response(status=404)
            return Response(rule)

        async def patch(self, request, rule_id):
            """
            Modify an existing lookup rule
            """
            rule = await self.storage.lookups.get(rule_id)
            if not rule:
                return Response(status=404)

            request = await request.json()

            data = {
                'id': rule_id,
                'name': request.get('name', rule['name']),
                'config': request.get('config', rule['config'])
            }

            await self.storage.lookups.insert(data)
            return Response(data)

        async def delete(self, request, rule_id):
            """
            Delete the rule with id `rule_id`
            """
            rule = await self.storage.lookups.get(rule_id)
            if not rule:
                return Response(status=404)

            await self.storage.lookups.delete(rule_id=rule_id)
            return Response(rule)

    @resource('/test', version='v1')
    class Test:

        async def post(self, request):
            # Send data to all topics
            await self.TestTopic.post(self, request, None)

    @resource('/test/{topic}', version='v1')
    class TestTopic:

        async def post(self, request, topic):
            # Send data to the given topic
            await self.workflow_event(topic, await request.json())
