# Passerelle - uniform access to data and services
# Copyright (C) 2019  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 inspect
import os
import re
import xml.etree.ElementTree as ET
import zipfile
from collections import namedtuple

from django.contrib.postgres.fields import JSONField
from django.db import IntegrityError, models, transaction
from django.http import HttpResponse
from django.urls import reverse
from django.utils import functional
from django.utils.translation import gettext_lazy as _

from passerelle.base.models import BaseResource, SkipJob
from passerelle.utils import sftp, xml
from passerelle.utils.api import endpoint
from passerelle.utils.conversion import exception_to_text
from passerelle.utils.jsonresponse import APIError
from passerelle.utils.zip import ZipTemplate

# Base abstract models for implementing MDEL compatible requests.

MDELStatus = namedtuple('MDELStatus', ['code', 'slug', 'label'])

MDEL_STATUSES = map(
    lambda t: MDELStatus(*t),
    [
        ('100', 'closed', _('closed')),
        ('20', 'rejected', _('rejected')),
        ('19', 'accepted', _('accepted')),
        ('17', 'information needed', _('information needed')),
        ('16', 'in progress', _('in progress')),
        ('15', 'invalid', _('invalid')),
        ('14', 'imported', _('imported')),
    ],
)

MDEL_STATUSES_BY_CODE = {mdel_status.code: mdel_status for mdel_status in MDEL_STATUSES}


class Resource(BaseResource):
    outgoing_sftp = sftp.SFTPField(
        verbose_name=_('Outgoing SFTP'),
        blank=True,
        help_text=_('MDEL request .zip will be pushed to.'),
    )
    incoming_sftp = sftp.SFTPField(
        verbose_name=_('Incoming SFTP'),
        blank=True,
        help_text=_('MDEL response .zip will be pulled from.'),
    )
    recipient_siret = models.CharField(verbose_name=_('SIRET'), max_length=128)
    recipient_service = models.CharField(verbose_name=_('Service'), max_length=128)
    recipient_guichet = models.CharField(verbose_name=_('Guichet'), max_length=128)
    code_insee = models.CharField(verbose_name=_('INSEE Code'), max_length=6)

    xsd_path = 'schema.xsd'
    xsd_root_element = None
    flow_type = 'flow_type CHANGEME'
    doc_type = 'doc_type CHANGEME'
    zip_manifest = 'mdel/zip/manifest.json'
    code_insee_id = 'CODE_INSEE'
    xmlschema_class = xml.JSONSchemaFromXMLSchema

    class Meta:
        abstract = True

    def check_status(self):
        if self.outgoing_sftp:
            with self.outgoing_sftp.client() as out_sftp:
                out_sftp.listdir()
            if self.incoming_sftp:
                with self.incoming_sftp.client() as in_sftp:
                    in_sftp.listdir()

    @classmethod
    def get_doc_xml_schema(cls):
        base_dir = os.path.dirname(inspect.getfile(cls))
        path = os.path.join(base_dir, cls.xsd_path)
        assert os.path.exists(path)
        return cls.xmlschema_class(path, cls.xsd_root_element)

    @classmethod
    def get_doc_json_schema(cls):
        return cls.get_doc_xml_schema().json_schema

    @classmethod
    def get_create_schema(cls):
        base_schema = cls.get_doc_json_schema()
        base_schema['unflatten'] = True
        base_schema['merge_extra'] = True
        base_schema['properties'].update(
            {
                'display_id': {'type': 'string'},
                'email': {'type': 'string'},
                'code_insee': {'type': 'string'},
            }
        )
        base_schema.setdefault('required', []).append('display_id')
        if hasattr(cls, 'pre_process_create'):
            base_schema['pre_process'] = cls.pre_process_create
        return base_schema

    def _handle_create(self, request, payload):
        reference = 'A-' + payload['display_id']
        with transaction.atomic():
            try:
                demand = self.demand_set.create(reference=reference, step=1, data=payload)
            except IntegrityError as e:
                raise APIError(
                    'reference-non-unique', http_status=400, data={'original_exc': exception_to_text(e)}
                )
            self.add_job('push_demand', demand_id=demand.id)
            return self.status(request, demand)

    def push_demand(self, demand_id):
        demand = self.demand_set.get(id=demand_id)
        if not demand.push():
            raise SkipJob(after_timestamp=3600 * 6)

    @endpoint(
        perm='can_access', methods=['get'], description=_('Demand status'), pattern=r'(?P<demand_id>\d+)/$'
    )
    def demand(self, request, demand_id):
        try:
            demand = self.demand_set.get(id=demand_id)
        except self.demand_set.model.DoesNotExist:
            raise APIError('demand not found', http_status=404)
        return self.status(request, demand)

    def status(self, request, demand):
        return {
            'id': demand.id,
            'status': demand.status,
            'url': request.build_absolute_uri(demand.status_url),
            'zip_url': request.build_absolute_uri(demand.zip_url),
        }

    @endpoint(
        perm='can_access',
        methods=['get'],
        description=_('Demand document'),
        pattern=r'(?P<demand_id>\d+)/.*$',
    )
    def document(self, request, demand_id):
        try:
            demand = self.demand_set.get(id=demand_id)
        except self.demand_set.model.DoesNotExist:
            raise APIError('demand not found', http_status=404)
        response = HttpResponse(demand.zip_content, content_type='application/octet-stream')
        response['Content-Disposition'] = 'inline; filename=%s' % demand.zip_name
        return response

    @property
    def response_re(self):
        return re.compile(r'(?P<reference>[^-]+-[^-]+-[^-]+)-%s-' r'(?P<step>\d+).zip' % self.flow_type)

    def hourly(self):
        '''Get responses'''
        if not self.incoming_sftp:
            return
        try:
            with self.incoming_sftp.client() as client:
                for name in client.listdir():
                    m = self.response_re.match(name)
                    if not m:
                        self.logger.warning(
                            'pull responses: unexpected file "%s" in sftp, file name does not match pattern %s',
                            name,
                            self.response_re,
                        )
                        continue
                    reference = m.groupdict()['reference']
                    step = int(m.groupdict()['step'])
                    demand = self.demand_set.filter(reference=reference).first()
                    if not demand:
                        self.logger.error(
                            'pull responses: unexpected file "%s" in sftp, no demand for reference "%s"',
                            name,
                            reference,
                        )
                        continue
                    if step < demand.step:
                        demand.logger.error(
                            'pull responses: unexpected file "%s" in sftp: step %s is inferior to demand step %s',
                            name,
                            step,
                            demand.step,
                        )
                        continue
                    demand.handle_response(sftp_client=client, filename=name, step=step)
        except sftp.paramiko.SSHException as e:
            self.logger.error('pull responses: sftp error %s', e)
            return


class Demand(models.Model):
    STATUS_PENDING = 'pending'
    STATUS_PUSHED = 'pushed'
    STATUS_ERROR = 'error'

    STATUSES = [
        (STATUS_PENDING, _('pending')),
        (STATUS_PUSHED, _('pushed')),
        (STATUS_ERROR, _('error')),
    ]
    for mdel_status in MDEL_STATUSES:
        STATUSES.append((mdel_status.slug, mdel_status.label))

    created_at = models.DateTimeField(auto_now_add=True)
    updated_at = models.DateTimeField(auto_now=True)
    reference = models.CharField(max_length=32, null=False, unique=True)
    status = models.CharField(max_length=32, null=True, choices=STATUSES, default=STATUS_PENDING)
    step = models.IntegerField(default=0)
    data = JSONField()

    @functional.cached_property
    def logger(self):
        return self.resource.logger.context(
            demand_id=self.id, demand_status=self.status, demand_reference=self.reference
        )

    def push(self):
        if not self.resource.outgoing_sftp:
            return False
        try:
            with self.resource.outgoing_sftp.client() as client:
                with client.open(self.zip_name, mode='w') as fd:
                    fd.write(self.zip_content)
        except sftp.paramiko.SSHException as e:
            self.logger.error('push demand: %s failed, "%s"', self, exception_to_text(e))
            self.status = self.STATUS_ERROR
        except Exception as e:
            self.logger.exception('push demand: %s failed, "%s"', self, exception_to_text(e))
            self.status = self.STATUS_ERROR
        else:
            self.resource.logger.info('push demand: %s success', self)
            self.status = self.STATUS_PUSHED
        self.save()
        return True

    @functional.cached_property
    def zip_template(self):
        return ZipTemplate(
            self.resource.zip_manifest,
            ctx={
                'reference': self.reference,
                'flow_type': self.resource.flow_type,
                'doc_type': self.resource.doc_type,
                'step': '1',  # We never create more than one document for a reference
                'siret': self.resource.recipient_siret,
                'service': self.resource.recipient_service,
                'guichet': self.resource.recipient_guichet,
                'code_insee': self.data.get('code_insee', self.resource.code_insee),
                'document': self.document,
                'code_insee_id': self.resource.code_insee_id,
                'date': self.created_at.isoformat(),
                'email': self.data.get('email', ''),
            },
        )

    @property
    def zip_name(self):
        return self.zip_template.name

    @property
    def zip_content(self):
        return self.zip_template.render_to_bytes()

    @property
    def document(self):
        xml_schema = self.resource.get_doc_xml_schema()
        node = xml_schema.encode(self.data)
        node.attrib['xmlns:xs'] = 'http://www.w3.org/2001/XMLSchema'
        return b'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\n' + ET.tostring(node)

    @property
    def status_url(self):
        return reverse(
            'generic-endpoint',
            kwargs={
                'connector': self.resource.get_connector_slug(),
                'slug': self.resource.slug,
                'endpoint': 'demand',
                'rest': '%s/' % self.id,
            },
        )

    @property
    def zip_url(self):
        return reverse(
            'generic-endpoint',
            kwargs={
                'connector': self.resource.get_connector_slug(),
                'slug': self.resource.slug,
                'endpoint': 'document',
                'rest': '%s/%s' % (self.id, self.zip_name),
            },
        )

    def handle_response(self, sftp_client, filename, step):
        try:
            with sftp_client.open(filename) as fd:
                with zipfile.ZipFile(fd) as zip_file:
                    with zip_file.open('message.xml') as fd:
                        tree = ET.parse(fd)
                ns = 'http://finances.gouv.fr/dgme/pec/message/v1'
                etat_node = tree.find('.//{%s}Etat' % ns)
                if etat_node is None:
                    self.logger.error('pull responses: missing Etat node in "%s"', filename)
                    return
                etat = etat_node.text
                if etat in MDEL_STATUSES_BY_CODE:
                    self.status = MDEL_STATUSES_BY_CODE[etat].slug
                else:
                    self.logger.error('pull responses: unknown etat in "%s", etat="%s"', filename, etat)
                    return
                commentaire_node = tree.find('.//{%s}Etat' % ns)
                if commentaire_node is not None:
                    commentaire = commentaire_node.text
                    self.data = self.data or {}
                    self.data.setdefault('commentaires', []).append(commentaire)
                    self.data['commentaire'] = commentaire
                self.step = step + 1
                self.save()
                self.logger.info('pull responses: status of demand %s changed to %s', self, self.status)
        except sftp.paramiko.SSHException as e:
            self.logger.error(
                'pull responses: failed to read response "%s", %s', filename, exception_to_text(e)
            )
        else:
            try:
                sftp_client.remove(filename)
            except sftp.paramiko.SSHException as e:
                self.logger.error(
                    'pull responses: failed to remove response "%s", %s', filename, exception_to_text(e)
                )

    def __str__(self):
        return '<Demand %s reference:%s flow_type:%s>' % (self.id, self.reference, self.resource.flow_type)

    class Meta:
        abstract = True
