# passerelle - uniform access to multiple data sources and services
# Copyright (C) 2021 Entr'ouvert
#
# This program is free software: you can redistribute it and/or modify it
# under the terms of the GNU Affero General Public License as published
# by the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program.  If not, see <http://www.gnu.org/licenses/>.

import base64
import datetime
import json
from urllib import parse as urlparse
from uuid import uuid4

import lxml.etree as ET
from django.conf import settings
from django.contrib.postgres.fields import JSONField
from django.core.exceptions import ValidationError
from django.core.files.base import ContentFile
from django.db import models
from django.db.transaction import atomic
from django.urls import reverse
from django.utils.text import slugify
from django.utils.timezone import now
from django.utils.translation import gettext_lazy as _
from requests import RequestException

from passerelle.base.models import BaseResource, HTTPResource, SkipJob
from passerelle.utils import xml
from passerelle.utils.api import endpoint
from passerelle.utils.jsonresponse import APIError
from passerelle.utils.wcs import WcsApi, WcsApiError

from . import schemas, utils


class ToulouseSmartResource(BaseResource, HTTPResource):
    category = _('Business Process Connectors')

    webservice_base_url = models.URLField(_('Webservice Base URL'))

    log_requests_errors = False

    class Meta:
        verbose_name = _('Toulouse Smart')

    def get_intervention_types(self):
        try:
            return self.get('intervention-types', max_age=120)
        except KeyError:
            pass

        try:
            url = self.webservice_base_url + 'v1/type-intervention'
            response = self.requests.get(url, timeout=10)
            doc = ET.fromstring(response.content)
            intervention_types = []
            for xml_item in doc:
                item = xml.to_json(xml_item)
                properties = item.pop('properties', [])
                for prop in properties:
                    prop['required'] = prop.get('required') == 'true'
                    if prop.get('restrictedValues'):
                        prop['type'] = 'item'
                    if prop.get('type') in ('string', 'int', 'boolean', 'item'):
                        if 'properties' not in item:
                            item['properties'] = []
                        item['properties'].append(prop)
                intervention_types.append(item)
            intervention_types.sort(key=lambda x: x['name'])
            for i, intervention_type in enumerate(intervention_types):
                intervention_type['order'] = i + 1
        except Exception:
            return self.get('intervention-types')
        self.set('intervention-types', intervention_types)
        return intervention_types

    def get(self, key, max_age=None):
        cache_entries = self.cache_entries
        if max_age:
            cache_entries = cache_entries.filter(timestamp__gt=now() - datetime.timedelta(seconds=max_age))
        try:
            return cache_entries.get(key=key).value
        except Cache.DoesNotExist:
            raise KeyError(key)

    def set(self, key, value):
        self.cache_entries.update_or_create(key=key, defaults={'value': value})

    @endpoint(
        name='type-intervention',
        description=_('Get intervention types'),
        perm='can_access',
    )
    def type_intervention(self, request):
        try:
            return {
                'data': [
                    {
                        'id': slugify(intervention_type['name']),
                        'text': intervention_type['name'],
                        'uuid': intervention_type['id'],
                    }
                    for intervention_type in self.get_intervention_types()
                ]
            }
        except Exception:
            return {
                'data': [
                    {
                        'id': '',
                        'text': _('Service is unavailable'),
                        'disabled': True,
                    }
                ]
            }

    def request(self, url, method='GET', json=None, files=None, timeout=None):
        headers = {'Accept': 'application/json'}
        if method == 'POST':
            headers['Content-Type'] = 'application/json'
        kwargs = {
            'headers': headers,
            'json': json,
            'files': files,
        }
        if timeout:
            kwargs['timeout'] = timeout
        try:
            response = self.requests.request(method=method, url=url, **kwargs)
            response.raise_for_status()
        except RequestException as e:
            raise APIError('failed to %s %s: %s' % (method.lower(), url, e))
        return response

    @endpoint(
        name='get-intervention',
        methods=['get'],
        description=_('Retrieve an intervention'),
        perm='can_access',
        parameters={
            'id': {'description': _('Intervention identifier')},
        },
    )
    def get_intervention(self, request, id):
        url = self.webservice_base_url + 'v1/intervention/%s' % id
        response = self.request(url)
        data = response.json()
        for label in 'interventionCreated', 'interventionDesired':
            data[label] = utils.utc_to_localtz(data[label])
        return {'data': data}

    @atomic
    @endpoint(
        name='create-intervention',
        methods=['post'],
        description=_('Create an intervention'),
        perm='can_access',
        post={'request_body': {'schema': {'application/json': schemas.CREATE_SCHEMA}}},
    )
    def create_intervention(self, request, post_data):
        slug = post_data['slug']
        try:
            types = [x for x in self.get_intervention_types() if slugify(x['name']) == slug]
        except KeyError:
            raise APIError('Service is unavailable')
        if len(types) == 0:
            raise APIError("unknown '%s' block slug" % slug, http_status=400)
        intervention_type = types[0]
        wcs_block_varname = slugify(intervention_type['name']).replace('-', '_')
        try:
            block = post_data['fields']['%s_raw' % wcs_block_varname][0]
        except (KeyError, TypeError):
            block = {}
        data = {}
        cast = {'string': str, 'int': int, 'boolean': bool, 'item': str}
        for prop in intervention_type.get('properties') or []:
            varname = slugify(prop['name']).replace('-', '_')
            if block.get(varname):
                try:
                    data[prop['name']] = cast[prop['type']](block[varname])
                except ValueError:
                    raise APIError(
                        "cannot cast '%s' field to %s : '%s'" % (varname, cast[prop['type']], block[varname]),
                        http_status=400,
                    )
            elif prop['required']:
                raise APIError("'%s' field is required on '%s' block" % (varname, slug), http_status=400)

        wcs_request, created = self.wcs_requests.get_or_create(
            wcs_form_api_url=post_data['form_api_url'],
            wcs_form_step=post_data.get('form_step', 'initial'),
            defaults={'wcs_form_number': post_data['external_number']},
        )
        wcs_request = self.wcs_requests.select_for_update().get(pk=wcs_request.pk)
        if not created:
            if wcs_request.status != 'failed':
                return {'data': wcs_request.reply}
            wcs_request.tries = 0
            wcs_request.status = 'registered'
            wcs_request.result = None

        endpoint_url = {}
        for endpoint_name in 'update-intervention', 'add-media':
            endpoint_url[endpoint_name] = request.build_absolute_uri(
                reverse(
                    'generic-endpoint',
                    kwargs={'connector': 'toulouse-smart', 'endpoint': endpoint_name, 'slug': self.slug},
                )
            )
        wcs_request.payload = {
            'description': post_data['description'],
            'cityId': post_data['cityId'],
            'externalReferences': post_data['externalReferences'],
            'submitterFirstName': post_data['submitterFirstName'],
            'submitterLastName': post_data['submitterLastName'],
            'submitterMail': post_data['submitterMail'],
            'submitterPhone': post_data['submitterPhone'],
            'submitterAddress': post_data['submitterAddress'],
            'submitterType': post_data['submitterType'],
            'external_number': post_data['external_number'],
            'external_status': post_data['external_status'],
            'address': post_data['address'],
            'interventionData': data,
            'geom': {
                'type': 'Point',
                'coordinates': [post_data['lon'], post_data['lat']],
                'crs': 'EPSG:4326',
            },
            'interventionTypeId': intervention_type['id'],
            'notificationUrl': '%s?uuid=%s' % (endpoint_url['update-intervention'], wcs_request.uuid),
            'notification_url': '%s?uuid=%s' % (endpoint_url['update-intervention'], wcs_request.uuid),
            'add_media_url': '%s?uuid=%s' % (endpoint_url['add-media'], wcs_request.uuid),
        }
        for label in 'checkDuplicated', 'onPrivateLand', 'safeguardRequired':
            if str(post_data.get(label)).lower() in ['true', 'oui', '1']:
                wcs_request.payload[label] = 'true'
        for label in 'interventionCreated', 'interventionDesired':
            wcs_request.payload[label] = utils.localtz_to_utc(post_data[label])
        wcs_request.save()
        if not wcs_request.push(timeout=10):
            self.add_job(
                'create_intervention_job',
                pk=str(wcs_request.pk),
                natural_id='wcs-request-%s' % wcs_request.pk,
            )
        return {'data': wcs_request.reply}

    def create_intervention_job(self, *args, **kwargs):
        with atomic():
            wcs_request = self.wcs_requests.select_for_update().get(pk=kwargs['pk'])
            after_timestamp = None
            if not wcs_request.push():
                if wcs_request.tries < 5:
                    if wcs_request.tries == 3:
                        after_timestamp = datetime.timedelta(hours=1)
                    if wcs_request.tries == 4:
                        after_timestamp = datetime.timedelta(days=1)
                else:
                    wcs_request.status = 'failed'
                    wcs_request.save()
        payload = {'creation_response': wcs_request.reply}
        smart_request = wcs_request.smart_requests.create(payload=payload)
        self.add_job(
            'update_intervention_job',
            id=smart_request.id,
            natural_id='smart-request-%s' % smart_request.id,
        )
        if wcs_request.status == 'registered':
            raise SkipJob(after_timestamp)
        if wcs_request.status == 'failed':
            raise Exception(wcs_request.result)

    @endpoint(
        name='add-media',
        methods=['post'],
        description=_('Add a media'),
        parameters={
            'uuid': {'description': _('Notification identifier')},
        },
        perm='can_access',
        post={'request_body': {'schema': {'application/json': schemas.MEDIA_SCHEMA}}},
    )
    def add_media(self, request, uuid, post_data):
        try:
            wcs_request = self.wcs_requests.get(uuid=uuid)
        except WcsRequest.DoesNotExist:
            raise APIError("Cannot find intervention '%s'" % uuid, http_status=400)

        nb_registered = 0
        for media in post_data['files']:
            if not media:
                # silently ignore empty payload value
                continue
            wcs_request_file = wcs_request.files.create(
                filename=media['filename'], content_type=media['content_type']
            )
            with ContentFile(base64.b64decode(media['content'])) as media_content:
                wcs_request_file.content.save(media['filename'], media_content)
            self.add_job(
                'add_media_job',
                id=wcs_request_file.id,
                natural_id='wcs-request-file-%s' % wcs_request_file.id,
            )
            nb_registered += 1

        return {'data': {'uuid': wcs_request.uuid, 'nb_registered': nb_registered}}

    def add_media_job(self, *args, **kwargs):
        wcs_request_file = WcsRequestFile.objects.get(id=kwargs['id'])
        wcs_request = wcs_request_file.resource
        if wcs_request.status == 'failed':
            raise Exception('related wcs request failed')
        if wcs_request.status == 'registered':
            raise SkipJob(datetime.timedelta(minutes=10))

        if not wcs_request_file.push():
            raise SkipJob()

    @atomic
    @endpoint(
        name='update-intervention',
        methods=['post'],
        description=_('Update an intervention status'),
        parameters={
            'uuid': {'description': _('Notification identifier')},
        },
        post={'request_body': {'schema': {'application/json': schemas.UPDATE_SCHEMA}}},
    )
    def update_intervention(self, request, uuid, post_data):
        try:
            wcs_request = self.wcs_requests.get(uuid=uuid)
        except ValidationError as e:
            raise APIError(str(e), http_status=400)
        except WcsRequest.DoesNotExist:
            raise APIError("Cannot find intervention '%s'" % uuid, http_status=400)
        smart_request = wcs_request.smart_requests.create(payload=post_data)
        self.add_job(
            'update_intervention_job',
            id=smart_request.id,
            natural_id='smart-request-%s' % smart_request.id,
        )
        return {
            'data': {
                'wcs_form_api_url': wcs_request.wcs_form_api_url,
                'wcs_form_number': wcs_request.wcs_form_number,
                'uuid': wcs_request.uuid,
                'payload': smart_request.payload,
            }
        }

    def update_intervention_job(self, *args, **kwargs):
        smart_request = SmartRequest.objects.get(id=kwargs['id'])
        if not smart_request.push():
            raise SkipJob()


class Cache(models.Model):
    resource = models.ForeignKey(
        verbose_name=_('Resource'),
        to=ToulouseSmartResource,
        on_delete=models.CASCADE,
        related_name='cache_entries',
    )

    key = models.CharField(_('Key'), max_length=64)

    timestamp = models.DateTimeField(_('Timestamp'), auto_now=True)

    value = JSONField(_('Value'), default=dict)


class WcsRequest(models.Model):
    resource = models.ForeignKey(
        to=ToulouseSmartResource,
        on_delete=models.CASCADE,
        related_name='wcs_requests',
    )
    wcs_form_api_url = models.CharField(max_length=256)
    wcs_form_number = models.CharField(max_length=16)
    wcs_form_step = models.CharField(default='initial', max_length=32)
    uuid = models.UUIDField(default=uuid4, primary_key=True, editable=False)
    payload = JSONField(null=True)
    result = JSONField(null=True)
    status = models.CharField(
        max_length=20,
        default='registered',
        choices=(
            ('registered', _('Registered')),
            ('sent', _('Sent')),
            ('failed', _('Failed')),
        ),
    )
    tries = models.IntegerField(default=0)

    def push(self, timeout=None):
        self.tries += 1
        url = self.resource.webservice_base_url + 'v1/intervention'
        try:
            response = self.resource.request(url, method='POST', json=self.payload, timeout=timeout)
        except APIError as e:
            self.result = str(e)
            self.save()
            return False
        try:
            self.result = response.json()
        except ValueError:
            err_desc = 'invalid json, got: %s' % response.text
            self.result = err_desc
            self.save()
            return False
        for label in 'interventionCreated', 'interventionDesired':
            self.result[label] = utils.utc_to_localtz(self.result[label])
        self.status = 'sent'
        self.save()
        return True

    @property
    def reply(self):
        return {
            'wcs_form_api_url': self.wcs_form_api_url,
            'wcs_form_number': self.wcs_form_number,
            'uuid': str(self.uuid),
            'payload': self.payload,
            'result': self.result,
            'status': self.status,
            'tries': self.tries,
        }

    class Meta:
        unique_together = [
            ('wcs_form_api_url', 'wcs_form_step'),
        ]


def upload_to(wcs_request_file, filename):
    instance = wcs_request_file.resource.resource
    uuid = wcs_request_file.resource.uuid
    return '%s/%s/%s/%s' % (instance.get_connector_slug(), instance.slug, uuid, filename)


class WcsRequestFile(models.Model):
    resource = models.ForeignKey(
        to=WcsRequest,
        on_delete=models.CASCADE,
        related_name='files',
    )
    filename = models.CharField(max_length=256)
    content_type = models.CharField(max_length=256)
    content = models.FileField(upload_to=upload_to)

    def push(self):
        wcs_request = self.resource
        intervention_id = wcs_request.result.get('id')
        instance = wcs_request.resource
        url = '%sv1/intervention/%s/media' % (instance.webservice_base_url, intervention_id)
        files = {'media': (self.filename, self.content.open('rb'), self.content_type)}
        try:
            instance.request(url, method='PUT', files=files)
        except APIError:
            return False
        self.content.delete()
        return True


class SmartRequest(models.Model):
    resource = models.ForeignKey(
        to=WcsRequest,
        on_delete=models.CASCADE,
        related_name='smart_requests',
    )
    payload = JSONField()
    result = JSONField(null=True)

    def get_wcs_api(self, base_url):
        scheme, netloc, dummy, dummy, dummy, dummy = urlparse.urlparse(base_url)
        services = settings.KNOWN_SERVICES.get('wcs', {})
        service = None
        for service in services.values():
            remote_url = service.get('url')
            r_scheme, r_netloc, dummy, dummy, dummy, dummy = urlparse.urlparse(remote_url)
            if r_scheme == scheme and r_netloc == netloc:
                break
        else:
            return None
        return WcsApi(base_url, orig=service.get('orig'), key=service.get('secret'))

    def push(self):
        headers = {
            'Content-Type': 'application/json',
            'Accept': 'application/json',
        }
        base_url = '%shooks/update_intervention/' % (self.resource.wcs_form_api_url)
        wcs_api = self.get_wcs_api(base_url)
        if not wcs_api:
            err_desc = 'Cannot find wcs service for %s' % base_url
            self.result = err_desc
            self.save()
            return True
        try:
            result = wcs_api.post_json(self.payload, [], headers=headers)
        except WcsApiError as e:
            try:
                result = json.loads(e.args[3])
            except (ValueError):
                return False
        self.result = result
        self.save()
        return True
