# 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
from collections import defaultdict
from operator import itemgetter

from django.core.cache import cache
from django.db import models
from django.http import HttpResponse
from django.utils import dateformat
from django.utils.timezone import localtime
from django.utils.translation import gettext_lazy as _

from passerelle.base.models import BaseResource
from passerelle.contrib.utils import axel
from passerelle.utils.api import endpoint
from passerelle.utils.jsonresponse import APIError

from . import schemas, utils

WEEKDAYS = {
    0: 'monday',
    1: 'tuesday',
    2: 'wednesday',
    3: 'thursday',
    4: 'friday',
    5: 'saturday',
    6: 'sunday',
}


class CaluireAxel(BaseResource):

    wsdl_url = models.CharField(
        max_length=128, blank=False, verbose_name=_('WSDL URL'), help_text=_('Caluire Axel WSDL URL')
    )

    category = _('Business Process Connectors')
    _category_ordering = [_('Family account'), _('Schooling'), _('Invoices')]

    class Meta:
        verbose_name = _('Caluire Axel')

    def check_status(self):
        response = self.requests.get(self.wsdl_url)
        response.raise_for_status()

    def check_individu(self, post_data):
        family_id = post_data.pop('IDENTFAMILLE')
        for key in ['NAISSANCE', 'CODEPOSTAL', 'VILLE', 'TEL', 'MAIL']:
            post_data[key] = None
        try:
            result = schemas.find_individus(self, {'PORTAIL': {'FINDINDIVIDU': post_data}})
        except axel.AxelError as e:
            raise APIError(
                'Axel error: %s' % e,
                err_code='error',
                data={'xml_request': e.xml_request, 'xml_response': e.xml_response},
            )
        data = result.json_response['DATA']['PORTAIL']['FINDINDIVIDUS']
        for individu in data.get('INDIVIDU') or []:
            for famille in individu.get('FAMILLE') or []:
                if famille['IDENTFAMILLE'] == family_id:
                    place = famille['PLACE']
                    if place not in ['1', '2']:
                        # not RL1 or RL2
                        raise APIError('Wrong place in family', err_code='family-place-error-%s' % place)
                    return individu, result

        raise APIError('Person not found', err_code='not-found')

    @endpoint(
        display_category=_('Family account'),
        display_order=1,
        description=_('Create link between user and Caluire Axel'),
        perm='can_access',
        parameters={
            'NameID': {'description': _('Publik ID')},
        },
        post={
            'request_body': {
                'schema': {
                    'application/json': schemas.LINK_SCHEMA,
                }
            }
        },
    )
    def link(self, request, NameID, post_data):
        if not NameID:
            raise APIError('NameID is empty', err_code='bad-request', http_status=400)

        family_id = post_data['IDENTFAMILLE']
        try:
            data, result = self.check_individu(post_data)
        except APIError as e:
            if not hasattr(e, 'err_code') or e.err_code == 'error':
                raise
            raise APIError('Person not found', err_code='not-found')

        link, created = self.link_set.get_or_create(
            name_id=NameID, defaults={'family_id': family_id, 'person_id': data['IDENT']}
        )
        if not created and (link.family_id != family_id or link.person_id != data['IDENT']):
            raise APIError('Data conflict', err_code='conflict')
        return {
            'link': link.pk,
            'created': created,
            'family_id': link.family_id,
            'data': {
                'xml_request': result.xml_request,
                'xml_response': result.xml_response,
            },
        }

    def get_link(self, name_id):
        try:
            return self.link_set.get(name_id=name_id)
        except Link.DoesNotExist:
            raise APIError('Person not found', err_code='not-found')

    @endpoint(
        display_category=_('Family account'),
        display_order=2,
        description=_('Delete link between user and Caluire Axel'),
        methods=['post'],
        perm='can_access',
        parameters={
            'NameID': {'description': _('Publik ID')},
        },
    )
    def unlink(self, request, NameID):
        link = self.get_link(NameID)
        link_id = link.pk
        link.delete()
        return {'link': link_id, 'deleted': True, 'family_id': link.family_id}

    def get_family_data(self, family_id):
        cache_key = 'caluire-axel-%s-family-data-%s' % (self.pk, family_id)
        result = cache.get(cache_key)
        if result is not None:
            return result
        try:
            result = schemas.get_famille_individus(
                self, {'PORTAIL': {'GETFAMILLE': {'IDENTFAMILLE': family_id}}}
            )
        except axel.AxelError as e:
            raise APIError(
                'Axel error: %s' % e,
                err_code='error',
                data={'xml_request': e.xml_request, 'xml_response': e.xml_response},
            )

        family_data = result.json_response['DATA']['PORTAIL']['GETFAMILLE']

        family_data['CODE'] = family_data['CODE'][0]
        if family_data.get('RESPONSABLE1'):
            family_data['RESPONSABLE1'] = family_data['RESPONSABLE1'][0]
        if family_data.get('RESPONSABLE2'):
            family_data['RESPONSABLE2'] = family_data['RESPONSABLE2'][0]

        for child in family_data.get('MEMBRE', []):
            child['id'] = child['IDENT']
            child['text'] = '{} {}'.format(child['PRENOM'].strip(), child['NOM'].strip()).strip()

        cache.set(cache_key, family_data, 30)  # 30 seconds
        return family_data

    def get_child_data(self, family_id, child_id):
        family_data = self.get_family_data(family_id)
        for child in family_data.get('MEMBRE', []):
            if child['IDENT'] == child_id:
                return child
        return None

    @endpoint(
        display_category=_('Family account'),
        display_order=3,
        description=_("Get information about user's family"),
        perm='can_access',
        parameters={
            'NameID': {'description': _('Publik ID')},
        },
    )
    def family_info(self, request, NameID):
        link = self.get_link(NameID)
        family_data = self.get_family_data(link.family_id)
        family_data['family_id'] = link.family_id
        return {'data': family_data}

    @endpoint(
        display_category=_('Family account'),
        display_order=4,
        description=_("Get information about children"),
        perm='can_access',
        parameters={
            'NameID': {'description': _('Publik ID')},
        },
    )
    def children_info(self, request, NameID):
        link = self.get_link(NameID)
        family_data = self.get_family_data(link.family_id)
        return {'data': family_data.get('MEMBRE', [])}

    @endpoint(
        display_category=_('Family account'),
        display_order=5,
        description=_("Get information about a child"),
        perm='can_access',
        parameters={
            'NameID': {'description': _('Publik ID')},
            'idpersonne': {'description': _('Child ID')},
        },
    )
    def child_info(self, request, NameID, idpersonne):
        link = self.get_link(NameID)
        child_data = self.get_child_data(link.family_id, idpersonne)
        if child_data is None:
            raise APIError('Child not found', err_code='not-found')
        return {'data': child_data}

    @endpoint(
        display_category=_('Family account'),
        display_order=6,
        description=_('Upload attachments for child or family'),
        methods=['post'],
        perm='can_access',
        parameters={
            'NameID': {'description': _('Publik ID')},
        },
        post={
            'request_body': {
                'schema': {
                    'application/json': schemas.UPLOAD_ATTACHMENTS_SCHEMA,
                }
            }
        },
    )
    def upload_attachments(self, request, NameID, post_data):
        link = self.get_link(NameID)
        if post_data.get('child_id'):
            child_data = self.get_child_data(link.family_id, post_data['child_id'])
            if child_data is None:
                raise APIError('Child not found', err_code='not-found')

        reference_date = datetime.datetime.strptime(post_data['reference_date'], axel.json_date_format).date()
        reference_year = utils.get_reference_year_from_date(reference_date)

        attachments = []
        for attachment in post_data.get('attachments', []):
            attachments.append(
                {
                    'TYPEPIECE': attachment['attachment_type'],
                    'LIBELLE': attachment['label'],
                    'URLFILE': attachment.get('url'),
                }
            )

        data = {
            'IDENTFAMILLE': link.family_id,
            'IDENTINDIVIDU': post_data.get('child_id'),
            'ANNEE': str(reference_year),
        }
        if attachments:
            data['PIECE'] = attachments

        try:
            result = schemas.set_pieces(self, {'PORTAIL': {'SETPIECES': data}})
        except axel.AxelError as e:
            raise APIError(
                'Axel error: %s' % e,
                err_code='error',
                data={'xml_request': e.xml_request, 'xml_response': e.xml_response},
            )

        for status in result.json_response['DATA']['PORTAIL']['SETPIECES']['PIECE']:
            code = status['CODE']
            if code != 0:
                raise APIError(
                    'Wrong upload-attachments status', err_code='upload-attachments-code-error-%s' % code
                )

        return {
            'created': True,
            'data': {
                'xml_request': result.xml_request,
                'xml_response': result.xml_response,
            },
        }

    @endpoint(
        display_category=_('Schooling'),
        display_order=1,
        description=_("Get school list"),
        perm='can_access',
        parameters={
            'num': {'description': _('Address: number')},
            'street': {'description': _('Address: street')},
            'zipcode': {'description': _('Address: zipcode')},
            'city': {'description': _('Address: city')},
            'schooling_date': {'description': _('Schooling date (to get reference year)')},
            'school_level': {'description': _('Requested school level')},
        },
    )
    def school_list(self, request, num, street, zipcode, city, schooling_date, school_level=None):
        try:
            schooling_date = datetime.datetime.strptime(schooling_date, axel.json_date_format)
        except ValueError:
            raise APIError('bad date format, should be YYYY-MM-DD', err_code='bad-request', http_status=400)

        reference_year = utils.get_reference_year_from_date(schooling_date)
        try:
            result = schemas.get_list_ecole(
                self,
                {
                    'PORTAIL': {
                        'GETLISTECOLE': {
                            'NORUE': num,
                            'ADRESSE1': street,
                            'CODEPOSTAL': zipcode,
                            'VILLE': city,
                            'IDENTNIVEAU': school_level or '',
                            'ANNEE': str(reference_year),
                        }
                    }
                },
            )
        except axel.AxelError as e:
            raise APIError(
                'Axel error: %s' % e,
                err_code='error',
                data={'xml_request': e.xml_request, 'xml_response': e.xml_response},
            )

        school_data = result.json_response['DATA']['PORTAIL']['GETLISTECOLE']
        for school in school_data.get('ECOLE', []):
            school['id'] = school['IDENT']
            school['text'] = school['LIBELLE']

        return {'data': school_data}

    @endpoint(
        display_category=_('Schooling'),
        display_order=2,
        description=_("Get information about schooling of a child"),
        perm='can_access',
        parameters={
            'NameID': {'description': _('Publik ID')},
            'idpersonne': {'description': _('Child ID')},
            'schooling_date': {'description': _('Schooling date (to get reference year)')},
        },
    )
    def child_schooling_info(self, request, NameID, idpersonne, schooling_date):
        link = self.get_link(NameID)
        try:
            schooling_date = datetime.datetime.strptime(schooling_date, axel.json_date_format)
        except ValueError:
            raise APIError('bad date format, should be YYYY-MM-DD', err_code='bad-request', http_status=400)

        child_data = self.get_child_data(link.family_id, idpersonne)
        if child_data is None:
            raise APIError('Child not found', err_code='not-found')

        reference_year = utils.get_reference_year_from_date(schooling_date)
        try:
            result = schemas.get_individu(
                self,
                {'PORTAIL': {'GETINDIVIDU': {'IDENTINDIVIDU': idpersonne, 'ANNEE': str(reference_year)}}},
            )
        except axel.AxelError as e:
            raise APIError(
                'Axel error: %s' % e,
                err_code='error',
                data={'xml_request': e.xml_request, 'xml_response': e.xml_response},
            )

        schooling_data = result.json_response['DATA']['PORTAIL']['GETINDIVIDU']

        return {'data': schooling_data}

    def get_child_activities(self, child_id, reference_year):
        cache_key = 'caluire-axel-%s-child-activities-%s-%s' % (self.pk, child_id, reference_year)
        result = cache.get(cache_key)
        if result is not None:
            return result

        try:
            result = schemas.get_list_activites(
                self,
                {'PORTAIL': {'GETLISTACTIVITES': {'IDENTINDIVIDU': child_id, 'ANNEE': str(reference_year)}}},
            )
        except axel.AxelError as e:
            raise APIError(
                'Axel error: %s' % e,
                err_code='error',
                data={'xml_request': e.xml_request, 'xml_response': e.xml_response},
            )

        activities_data = result.json_response['DATA']['PORTAIL']['GETLISTACTIVITES']

        cache.set(cache_key, activities_data, 30)  # 30 seconds
        return activities_data

    def get_activity_type(self, activity_id):
        if activity_id.startswith('CJ MER'):
            return 'mercredi'
        if activity_id.startswith('CJ'):
            return 'vacances'
        if activity_id in ['ACCMAT', 'NAV MATIN']:
            return 'matin'
        if activity_id in ['ETUDES', 'GARDERIES', 'NAV SOIR']:
            return 'soir'
        return 'midi'

    def get_child_activity(self, child_id, activity_id, reference_year):
        activities_data = self.get_child_activities(child_id, reference_year)
        for activity in activities_data.get('ACTIVITE', []):
            if activity['IDENTACTIVITE'] == activity_id:
                return activity
        return None

    @endpoint(
        display_category=_('Schooling'),
        display_order=3,
        description=_("Get information about activities of a child for the year"),
        perm='can_access',
        parameters={
            'NameID': {'description': _('Publik ID')},
            'idpersonne': {'description': _('Child ID')},
            'schooling_date': {'description': _('Schooling date (to get reference year)')},
        },
    )
    def child_activities_info(self, request, NameID, idpersonne, schooling_date):
        link = self.get_link(NameID)
        try:
            schooling_date = datetime.datetime.strptime(schooling_date, axel.json_date_format)
        except ValueError:
            raise APIError('bad date format, should be YYYY-MM-DD', err_code='bad-request', http_status=400)

        child_data = self.get_child_data(link.family_id, idpersonne)
        if child_data is None:
            raise APIError('Child not found', err_code='not-found')

        reference_year = utils.get_reference_year_from_date(schooling_date)
        activities_data = self.get_child_activities(idpersonne, reference_year)

        return {'data': activities_data}

    def get_start_and_end_dates(self, start_date, end_date):
        try:
            start_date = datetime.datetime.strptime(start_date, axel.json_date_format).date()
            end_date = datetime.datetime.strptime(end_date, axel.json_date_format).date()
        except ValueError:
            raise APIError('bad date format, should be YYYY-MM-DD', err_code='bad-request', http_status=400)

        if start_date > end_date:
            raise APIError(
                'start_date should be before end_date',
                err_code='bad-request',
                http_status=400,
            )
        reference_year = utils.get_reference_year_from_date(start_date)
        end_reference_year = utils.get_reference_year_from_date(end_date)
        if reference_year != end_reference_year:
            raise APIError(
                'start_date and end_date are in different reference year (%s != %s)'
                % (reference_year, end_reference_year),
                err_code='bad-request',
                http_status=400,
            )
        return start_date, end_date, reference_year

    @endpoint(
        display_category=_('Schooling'),
        display_order=4,
        description=_("Register a child for an activity"),
        perm='can_access',
        parameters={
            'NameID': {'description': _('Publik ID')},
        },
        post={
            'request_body': {
                'schema': {
                    'application/json': schemas.REGISTER_ACTIVITY_SCHEMA,
                }
            }
        },
    )
    def register_activity(self, request, NameID, post_data):
        link = self.get_link(NameID)
        child_data = self.get_child_data(link.family_id, post_data['child_id'])
        if child_data is None:
            raise APIError('Child not found', err_code='not-found')
        child_data.pop('id', None)
        child_data.pop('text', None)
        child_data.pop('FAMILLE', None)

        start_date, end_date, reference_year = self.get_start_and_end_dates(
            post_data['registration_start_date'], post_data['registration_end_date']
        )

        # build data
        data = {
            'IDENTFAMILLE': link.family_id,
            'INDIVIDU': child_data,
            'ACTIVITE': {
                'ANNEE': str(reference_year),
                'IDENTACTIVITE': post_data['activity_id'],
                'ENTREE': start_date.strftime(axel.json_date_format),
                'SORTIE': end_date.strftime(axel.json_date_format),
            },
        }

        try:
            result = schemas.create_inscription_activite(
                self, {'PORTAIL': {'CREATEINSCRIPTIONACTIVITE': data}}
            )
        except axel.AxelError as e:
            raise APIError(
                'Axel error: %s' % e,
                err_code='error',
                data={'xml_request': e.xml_request, 'xml_response': e.xml_response},
            )

        code = result.json_response['DATA']['PORTAIL']['CREATEINSCRIPTIONACTIVITE']['CODE']
        if code < -1:
            raise APIError(
                'Wrong register-activity status', err_code='register-activity-code-error-%s' % code
            )

        # invalidate get_children_activities cache
        cache_key = 'caluire-axel-%s-child-activities-%s-%s' % (
            self.pk,
            post_data['child_id'],
            reference_year,
        )
        cache.delete(cache_key)

        return {
            'created': bool(code == 0),
            'data': {
                'xml_request': result.xml_request,
                'xml_response': result.xml_response,
            },
        }

    def get_bookings(
        self,
        child_id,
        activity_id,
        start_date,
        end_date,
        activity_label=None,
        ignore_wednesday=False,
        ignore_weekend=False,
    ):
        data = {
            'IDENTINDIVIDU': child_id,
            'IDENTACTIVITE': activity_id,
            'DEBUT': start_date.strftime(axel.json_date_format),
            'FIN': end_date.strftime(axel.json_date_format),
        }

        try:
            result = schemas.get_agenda(self, {'PORTAIL': {'GETAGENDA': data}})
        except axel.AxelError as e:
            raise APIError(
                'Axel error: %s' % e,
                err_code='error',
                data={'xml_request': e.xml_request, 'xml_response': e.xml_response},
            )

        code = result.json_response['DATA']['PORTAIL']['GETAGENDA']['CODE']
        if code < 1:
            raise APIError('Wrong agenda status', err_code='agenda-code-error-%s' % code)

        # get pivot date
        date_now = localtime()
        pivot_date = None
        if activity_id.startswith('CJ'):
            # mercredi or vacances: not bookable
            pivot_date = None
        else:
            # cantine, accmat, garderies, etudes
            # 2 opened days required
            pivot_date = date_now.date()
            if date_now.hour >= 12:
                # after 12H, jump to tomorrow
                pivot_date += datetime.timedelta(days=1)
            if pivot_date.weekday() > 2:
                # add a day if after wednesday
                pivot_date += datetime.timedelta(days=1)
            # add 3 days
            pivot_date += datetime.timedelta(days=3)
            if pivot_date.weekday() > 4:
                # week-end, jump to next monday
                pivot_date += datetime.timedelta(days=7 - pivot_date.weekday())
            elif pivot_date.weekday() == 2:
                # wednesday, jump to next thursday
                pivot_date += datetime.timedelta(days=1)

        bookings = []
        activity_type = self.get_activity_type(activity_id)
        days = result.json_response['DATA']['PORTAIL']['GETAGENDA'].get('JOUR', [])
        for day in days:
            if day.get('FERME'):
                # hide closed days
                continue
            day_date = datetime.datetime.strptime(day['JOURDATE'], axel.json_date_format).date()
            if day_date.weekday() == 2 and ignore_wednesday:
                continue
            if day_date.weekday() >= 5 and ignore_weekend:
                continue
            booking = {
                'id': '%s:%s:%s' % (child_id, activity_id, day['JOURDATE']),
                'text': dateformat.format(day_date, 'l j F Y'),
                'prefill': day['MATIN'] in ['X', 'H', 'R'],
                'details': day,
            }
            color = 'grey'
            if day['MATIN'] in ['X', 'R']:
                color = 'green'
            elif day['MATIN'] in ['H']:
                color = 'yellow'
            elif day['MATIN'] in ['.']:
                color = 'white'
            elif day['MATIN'] in ['D', 'C', 'M']:
                color = 'orange'
            elif day['MATIN'] in ['A', 'N']:
                color = 'red'
            booking['details']['status_color'] = color

            if pivot_date is None or day_date < pivot_date:
                # not bookable or it's too late to book
                booking['details']['out_of_delay'] = True
                disabled = True
            else:
                booking['details']['out_of_delay'] = False
                if color in ['grey', 'yellow', 'red']:
                    disabled = True
                elif color == 'orange':
                    disabled = day['MATIN'] != 'D'
                else:
                    disabled = False
            booking['disabled'] = disabled
            booking['details']['activity_id'] = activity_id
            booking['details']['activity_type'] = activity_type
            if activity_label:
                booking['details']['activity_label'] = activity_label
            booking['details']['child_id'] = child_id
            bookings.append(booking)

        return bookings

    @endpoint(
        display_category=_('Schooling'),
        display_order=5,
        description=_("Get agenda for an activity and a child"),
        perm='can_access',
        parameters={
            'NameID': {'description': _('Publik ID')},
            'idpersonne': {'description': _('Child ID')},
            'activity_id': {'description': _('Activity ID')},
            'start_date': {'description': _('Start date of the period')},
            'end_date': {'description': _('End date of the period')},
        },
    )
    def get_agenda(self, request, NameID, idpersonne, activity_id, start_date, end_date):
        link = self.get_link(NameID)
        start_date, end_date, reference_year = self.get_start_and_end_dates(start_date, end_date)

        child_data = self.get_child_data(link.family_id, idpersonne)
        if child_data is None:
            raise APIError('Child not found', err_code='not-found')

        activity_data = self.get_child_activity(idpersonne, activity_id, reference_year)
        if activity_data is None:
            raise APIError('Activity not found', err_code='not-found')
        bookings = self.get_bookings(
            idpersonne, activity_id, start_date, end_date, activity_label=activity_data['LIBELLEACTIVITE']
        )
        return {'data': bookings}

    def _get_child_agenda(self, request, NameID, idpersonne, start_date, end_date, full=False):
        link = self.get_link(NameID)
        start_date, end_date, reference_year = self.get_start_and_end_dates(start_date, end_date)

        child_data = self.get_child_data(link.family_id, idpersonne)
        if child_data is None:
            raise APIError('Child not found', err_code='not-found')

        activities_data = self.get_child_activities(idpersonne, reference_year)
        bookings = []
        for activity in activities_data.get('ACTIVITE', []):
            activity_id = activity['IDENTACTIVITE']
            activity_label = activity['LIBELLEACTIVITE']
            if activity_id.startswith('CJ') and not full:
                # mercredi or vacances: ignore it
                continue
            bookings += self.get_bookings(
                idpersonne,
                activity_id,
                start_date,
                end_date,
                activity_label=activity_label,
                ignore_wednesday=not (full),
                ignore_weekend=True,
            )

        # sort bookings
        activity_types = ['matin', 'midi', 'soir', 'mercredi', 'vacances']
        activity_ids = ['NAV MATIN', 'ACCMAT', 'ETUDES', 'GARDERIES', 'NAV SOIR']
        bookings = [
            (
                b['details']['JOURDATE'],
                activity_types.index(b['details']['activity_type']),
                activity_ids.index(b['details']['activity_id'])
                if b['details']['activity_id'] in activity_ids
                else 0,
                b['details']['activity_label'],
                b,
            )
            for b in bookings
        ]
        bookings = sorted(bookings, key=itemgetter(0, 1, 2, 3))
        bookings = [b for d, a, i, l, b in bookings]

        return {
            'data': bookings,
            'extra_data': {
                'start_date': start_date,
                'end_date': end_date,
                'school_year': '%s/%s' % (reference_year, reference_year + 1),
            },
        }

    @endpoint(
        display_category=_('Schooling'),
        display_order=6,
        description=_("Get periscolaire agenda for a child"),
        perm='can_access',
        parameters={
            'NameID': {'description': _('Publik ID')},
            'idpersonne': {'description': _('Child ID')},
            'start_date': {'description': _('Start date of the period')},
            'end_date': {'description': _('End date of the period')},
        },
    )
    def get_agenda_periscolaire(self, request, NameID, idpersonne, start_date, end_date):
        return self._get_child_agenda(request, NameID, idpersonne, start_date, end_date)

    @endpoint(
        display_category=_('Schooling'),
        display_order=7,
        description=_("Get full agenda for a child"),
        perm='can_access',
        parameters={
            'NameID': {'description': _('Publik ID')},
            'idpersonne': {'description': _('Child ID')},
            'start_date': {'description': _('Start date of the period')},
            'end_date': {'description': _('End date of the period')},
        },
    )
    def get_agenda_full(self, request, NameID, idpersonne, start_date, end_date):
        return self._get_child_agenda(request, NameID, idpersonne, start_date, end_date, full=True)

    def set_bookings(self, child_id, activity_id, start_date, end_date, booking_list, legacy_agenda=None):
        agenda = legacy_agenda or self.get_bookings(child_id, activity_id, start_date, end_date)
        legacy_days = [b['id'] for b in agenda if b['prefill'] is True]
        available_days = [b['id'] for b in agenda if b['disabled'] is False]
        booking_date = start_date
        bookings = []
        updated = []
        while booking_date <= end_date:
            day = booking_date.strftime(axel.json_date_format)
            day_id = '%s:%s:%s' % (child_id, activity_id, day)
            booked = None
            if day_id not in available_days:
                # disabled or not available: not bookable
                booked = None
            elif day_id not in legacy_days and day_id in booking_list:
                booked = 'X'
            elif day_id in legacy_days and day_id not in booking_list:
                booked = 'D'
            if booked is not None:
                # no changes, don't send the day
                bookings.append(
                    {
                        'JOURDATE': day,
                        'MATIN': booked,
                        'MIDI': None,
                        'APRESMIDI': None,
                    }
                )
                updated.append(
                    {
                        'activity_id': activity_id,
                        'day': booking_date,
                        'booked': booked == 'X',
                    }
                )
            booking_date += datetime.timedelta(days=1)
        if not bookings:
            # don't call axel if no changes
            return updated
        try:
            data = {
                'PORTAIL': {
                    'SETAGENDA': {
                        'IDENTINDIVIDU': child_id,
                        'ACTIVITE': {
                            'IDENTACTIVITE': activity_id,
                            'JOUR': bookings,
                        },
                    }
                }
            }
            result = schemas.set_agenda(self, data)
        except axel.AxelError as e:
            raise APIError(
                'Axel error: %s' % e,
                err_code='error',
                data={'xml_request': e.xml_request, 'xml_response': e.xml_response},
            )

        code = result.json_response['DATA']['PORTAIL']['SETAGENDA']['CODE']
        if code != 0:
            raise APIError('Wrong agenda status', err_code='agenda-code-error-%s' % code)

        return updated

    def _set_agenda(self, child_id, start_date, end_date, activities_data, current_agenda, booking_list):
        # check exclusive activities
        exclusive_activity_ids = ['ACCMAT', 'NAV MATIN', 'GARDERIES', 'ETUDES', 'NAV SOIR']
        # build list of requested booked days
        exclusive_activities = defaultdict(set)
        for booking in booking_list:
            _child_id, activity_id, day = booking.split(':')
            if _child_id != child_id:
                continue
            if activity_id not in exclusive_activity_ids:
                continue
            exclusive_activities["%s:%s" % (day, self.get_activity_type(activity_id))].add(booking)
        # build list of existing booked days
        legacy_exclusive_activities = defaultdict(set)
        for activity_id in exclusive_activity_ids:
            legacy_agenda = current_agenda.get(activity_id) or []
            for booking in legacy_agenda:
                if booking['prefill'] is not True:
                    continue
                _child_id, activity_id, day = booking['id'].split(':')
                if _child_id != child_id:
                    continue
                if activity_id not in exclusive_activity_ids:
                    continue
                legacy_exclusive_activities["%s:%s" % (day, self.get_activity_type(activity_id))].add(
                    booking['id']
                )
        # check booking exclusivity for changes only
        for key, bookings in exclusive_activities.items():
            if len(legacy_exclusive_activities.get(key) or []) > 1:
                # it was already booked in Axel ...
                continue
            if len(bookings) > 1:
                raise APIError(
                    'not possible to book %s the same day' % ' and '.join(sorted(list(bookings))),
                    err_code='bad-request',
                    http_status=400,
                )

        updated = []
        for activity in activities_data.get('ACTIVITE', []):
            activity_id = activity['IDENTACTIVITE']
            if activity_id.startswith('CJ'):
                # mercredi or vacances: not bookable
                continue
            bookings = self.set_bookings(
                child_id,
                activity_id,
                start_date,
                end_date,
                booking_list,
                legacy_agenda=current_agenda[activity_id],
            )
            for booking in bookings:
                booking['activity_label'] = activity['LIBELLEACTIVITE']
                booking['activity_type'] = self.get_activity_type(activity_id)
            updated += bookings

        # sort changes
        activity_types = ['nav-matin', 'matin', 'midi', 'soir', 'nav-soir']
        updated = [
            (
                not u['booked'],
                activity_types.index(u['activity_type']),
                u['day'],
                u,
            )
            for u in updated
        ]
        updated = sorted(updated, key=itemgetter(0, 1, 2))
        updated = [u for b, a, d, u in updated]
        updated = [
            {
                'booked': u['booked'],
                'activity_id': u['activity_id'],
                'activity_label': u['activity_label'],
                'day': u['day'],
            }
            for u in updated
        ]

        return {
            'updated': True,
            'count': len(updated),
            'changes': updated,
        }

    @endpoint(
        display_category=_('Schooling'),
        display_order=8,
        description=_("Set agenda for a child"),
        perm='can_access',
        parameters={
            'NameID': {'description': _('Publik ID')},
        },
        post={
            'request_body': {
                'schema': {
                    'application/json': schemas.BOOKING_SCHEMA,
                }
            }
        },
    )
    def set_agenda(self, request, NameID, post_data):
        link = self.get_link(NameID)
        child_id = post_data['child_id']
        child_data = self.get_child_data(link.family_id, child_id)
        if child_data is None:
            raise APIError('Child not found', err_code='not-found')
        start_date, end_date, reference_year = self.get_start_and_end_dates(
            post_data['start_date'], post_data['end_date']
        )

        # get current agenda
        activities_data = self.get_child_activities(child_id, reference_year)
        agenda = {}
        for activity in activities_data.get('ACTIVITE', []):
            activity_id = activity['IDENTACTIVITE']
            if activity_id.startswith('CJ'):
                # mercredi or vacances: not bookable
                continue
            agenda[activity_id] = self.get_bookings(child_id, activity_id, start_date, end_date)

        return self._set_agenda(
            child_id, start_date, end_date, activities_data, agenda, post_data['booking_list']
        )

    @endpoint(
        display_category=_('Schooling'),
        display_order=9,
        description=_("Set agenda for a child, from changes applied to another child"),
        perm='can_access',
        parameters={
            'NameID': {'description': _('Publik ID')},
        },
        post={
            'request_body': {
                'schema': {
                    'application/json': schemas.CHANGES_SCHEMA,
                }
            }
        },
    )
    def set_agenda_apply_changes(self, request, NameID, post_data):
        link = self.get_link(NameID)
        child_id = post_data['child_id']
        child_data = self.get_child_data(link.family_id, child_id)
        if child_data is None:
            raise APIError('Child not found', err_code='not-found')
        start_date, end_date, reference_year = self.get_start_and_end_dates(
            post_data['start_date'], post_data['end_date']
        )

        # get current agenda
        activities_data = self.get_child_activities(child_id, reference_year)
        agenda = {}
        current_child_cantine_activity_id = None
        current_child_cantine_activity_ids = []
        current_child_activity_ids = set()
        for activity in activities_data.get('ACTIVITE', []):
            activity_id = activity['IDENTACTIVITE']
            if activity_id.startswith('CJ'):
                # mercredi or vacances: not bookable
                continue
            if self.get_activity_type(activity_id) == 'midi':
                current_child_cantine_activity_ids.append(activity_id)
            current_child_activity_ids.add(activity_id)
            agenda[activity_id] = self.get_bookings(child_id, activity_id, start_date, end_date)
        if len(current_child_cantine_activity_ids) > 1:
            raise APIError(
                'more than one activity cantine found for this child (%s)'
                % ', '.join(current_child_cantine_activity_ids),
                err_code='bad-request',
                http_status=400,
            )
        if current_child_cantine_activity_ids:
            current_child_cantine_activity_id = current_child_cantine_activity_ids[0]

        # create booking_list from current_agenda and changes
        changes = {}
        for change in post_data['changes']:
            activity_id = change['activity_id']
            if activity_id.startswith('CJ'):
                # mercredi or vacances: not bookable
                continue
            # activity_id can be different for cantine activity
            if self.get_activity_type(activity_id) == 'midi':
                activity_id = current_child_cantine_activity_id
            if activity_id not in current_child_activity_ids:
                # current child is not registered for this activity, skip
                continue
            changes['%s:%s:%s' % (child_id, activity_id, change['day'])] = change['booked']
        booking_list = []
        for activity_id, bookings in agenda.items():
            for booking in bookings:
                bid = booking['id']
                prefill = booking['prefill']
                if bid not in changes and prefill is True:
                    # no change, but already booked
                    booking_list.append(bid)
                elif bid in changes and changes[bid] is True:
                    # change, should be booked
                    booking_list.append(bid)

        return self._set_agenda(child_id, start_date, end_date, activities_data, agenda, booking_list)

    @endpoint(
        display_category=_('Schooling'),
        display_order=10,
        description=_("Set activity agenda for a child with a typical week"),
        perm='can_access',
        parameters={
            'NameID': {'description': _('Publik ID')},
        },
        post={
            'request_body': {
                'schema': {
                    'application/json': schemas.TYPICAL_WEEK_BOOKING_SCHEMA,
                }
            }
        },
    )
    def set_activity_agenda_typical_week(self, request, NameID, post_data):
        link = self.get_link(NameID)
        child_id = post_data['child_id']
        child_data = self.get_child_data(link.family_id, child_id)
        if child_data is None:
            raise APIError('Child not found', err_code='not-found')
        start_date, end_date, reference_year = self.get_start_and_end_dates(
            post_data['start_date'], post_data['end_date']
        )
        activity_id = post_data['activity_id']
        activity_data = self.get_child_activity(child_id, activity_id, reference_year)
        if activity_data is None:
            raise APIError('Activity not found', err_code='not-found')

        if activity_id.startswith('CJ'):
            raise APIError('Not available for this activity', err_code='bad-request', http_status=400)

        booking_date = start_date
        bookings = []
        while booking_date <= end_date:
            if WEEKDAYS[booking_date.weekday()] in post_data['booking_list']:
                bookings.append(
                    '%s:%s:%s' % (child_id, activity_id, booking_date.strftime(axel.json_date_format))
                )
            booking_date += datetime.timedelta(days=1)

        updated = self.set_bookings(child_id, activity_id, start_date, end_date, bookings)

        return {
            'updated': True,
            'count': len(updated),
        }

    def get_invoices(self, regie_id, family_id):
        try:
            result = schemas.get_factures_a_payer(
                self,
                {
                    'PORTAIL': {
                        'GETFACTURESAPAYER': {
                            'IDENTFAMILLE': family_id,
                            'IDENTREGIEFACT': regie_id,
                        }
                    }
                },
            )

        except axel.AxelError as e:
            raise APIError(
                'Axel error: %s' % e,
                err_code='error',
                data={'xml_request': e.xml_request, 'xml_response': e.xml_response},
            )

        code = result.json_response['DATA']['PORTAIL']['GETFACTURESAPAYER']['CODE']
        if code < 0:
            raise APIError('Wrong get-invoices status', err_code='get-invoices-code-error-%s' % code)

        data = result.json_response['DATA']['PORTAIL']['GETFACTURESAPAYER']
        result = []

        date_now = localtime().date()
        for facture in data.get('FACTURE', []):
            date_creation = datetime.datetime.strptime(facture['EMISSION'], axel.json_date_format).date()
            if date_creation <= date_now:
                result.append(utils.normalize_invoice(facture, family_id))
        return result

    def get_historical_invoices(self, regie_id, family_id, nb_mounts_limit):
        try:
            nb_mois = int(nb_mounts_limit)
        except ValueError:
            raise APIError('nb_mounts_limit must be an integer', err_code='bad-request', http_status=400)
        try:
            result = schemas.get_list_factures(
                self,
                {
                    'PORTAIL': {
                        'GETLISTFACTURES': {
                            'IDENTFAMILLE': family_id,
                            'IDENTREGIEFACT': regie_id,
                            'NBMOIS': nb_mois,
                        }
                    },
                },
            )
        except axel.AxelError as e:
            raise APIError(
                'Axel error: %s' % e,
                err_code='error',
                data={'xml_request': e.xml_request, 'xml_response': e.xml_response},
            )

        code = result.json_response['DATA']['PORTAIL']['GETLISTFACTURES']['CODE']
        if code < 0:
            raise APIError(
                'Wrong get-historical-invoices status',
                err_code='get-historical-invoices-code-error-%s' % code,
            )

        data = result.json_response['DATA']['PORTAIL']['GETLISTFACTURES']
        date_now = localtime().date()
        result = []
        for facture in data.get('FACTURE', []):
            date_creation = datetime.datetime.strptime(facture['EMISSION'], axel.json_date_format).date()
            if date_creation <= date_now:
                result.append(utils.normalize_invoice(facture, family_id, historical=True))
        return result

    def get_invoice(self, regie_id, invoice_id, family_id, historical=None, nb_mounts_limit=None):
        if historical:
            invoices_data = self.get_historical_invoices(
                regie_id=regie_id, family_id=family_id, nb_mounts_limit=nb_mounts_limit
            )
        else:
            invoices_data = self.get_invoices(regie_id=regie_id, family_id=family_id)
        for invoice in invoices_data:
            if invoice['display_id'] == invoice_id:
                return invoice

    @endpoint(
        display_category=_('Invoices'),
        display_order=1,
        name='regie',
        perm='can_access',
        pattern=r'^(?P<regie_id>[\w-]+)/invoices/?$',
        example_pattern='{regie_id}/invoices',
        description=_("Get invoices to pay"),
        parameters={
            'NameID': {'description': _('Publik ID')},
            'regie_id': {'description': _('Regie identifier'), 'example_value': 'ENF'},
        },
    )
    def invoices(self, request, regie_id, NameID):
        link = self.get_link(NameID)
        invoices_data = self.get_invoices(regie_id=regie_id, family_id=link.family_id)
        return {'data': invoices_data}

    @endpoint(
        display_category=_('Invoices'),
        display_order=2,
        name='regie',
        perm='can_access',
        pattern=r'^(?P<regie_id>[\w-]+)/invoices/history/?$',
        example_pattern='{regie_id}/invoices/history',
        description=_("Get invoices already paid"),
        parameters={
            'NameID': {'description': _('Publik ID')},
            'regie_id': {'description': _('Regie identifier'), 'example_value': 'ENF'},
            'nb_mounts_limit': {'description': _('Number of months of history'), 'example_value': '12'},
        },
    )
    def invoices_history(self, request, regie_id, NameID, nb_mounts_limit='12'):
        link = self.get_link(NameID)
        invoices_data = self.get_historical_invoices(
            regie_id, family_id=link.family_id, nb_mounts_limit=nb_mounts_limit
        )
        return {'data': invoices_data}

    @endpoint(
        display_category=_('Invoices'),
        display_order=3,
        name='regie',
        perm='can_access',
        pattern=r'^(?P<regie_id>[\w-]+)/invoice/(?P<invoice_id>(historical-)?\w+-\d+)/?$',
        example_pattern='{regie_id}/invoice/{invoice_id}',
        description=_('Get invoice details'),
        parameters={
            'NameID': {'description': _('Publik ID')},
            'regie_id': {'description': _('Regie identifier'), 'example_value': 'ENF'},
            'invoice_id': {'description': _('Invoice identifier'), 'example_value': 'IDFAM-42'},
            'nb_mounts_limit': {'description': _('Number of months of history'), 'example_value': '12'},
        },
    )
    def invoice(self, request, regie_id, invoice_id, NameID=None, nb_mounts_limit='12'):
        real_invoice_id = invoice_id.split('-')[-1]
        historical = invoice_id.startswith('historical-')
        if historical:
            invoice_id = invoice_id[len('historical-') :]
        family_id, real_invoice_id = invoice_id.split('-')
        invoice = self.get_invoice(
            regie_id=regie_id,
            invoice_id=real_invoice_id,
            family_id=family_id,
            historical=historical,
            nb_mounts_limit=nb_mounts_limit,
        )
        if invoice is None:
            raise APIError('Invoice not found', err_code='not-found')

        return {'data': invoice}

    @endpoint(
        display_category=_('Invoices'),
        display_order=4,
        name='regie',
        perm='can_access',
        pattern=r'^(?P<regie_id>[\w-]+)/invoice/(?P<invoice_id>(historical-)?\w+-\d+)/pdf/?$',
        example_pattern='{regie_id}/invoice/{invoice_id}/pdf',
        description=_('Get invoice as a PDF file'),
        parameters={
            'NameID': {'description': _('Publik ID')},
            'regie_id': {'description': _('Regie identifier'), 'example_value': 'ENF'},
            'invoice_id': {'description': _('Invoice identifier'), 'example_value': 'IDFAM-42'},
            'nb_mounts_limit': {'description': _('Number of months of history'), 'example_value': '12'},
        },
    )
    def invoice_pdf(self, request, regie_id, invoice_id, NameID, nb_mounts_limit='12'):
        # check that invoice is related to current user
        real_invoice_id = invoice_id.split('-')[-1]
        historical = invoice_id.startswith('historical-')
        try:
            link = self.get_link(NameID)
            invoice = self.get_invoice(
                regie_id=regie_id,
                invoice_id=real_invoice_id,
                family_id=link.family_id,
                historical=historical,
                nb_mounts_limit=nb_mounts_limit,
            )
        except APIError as e:
            e.http_status = 404
            raise
        if invoice is None:
            raise APIError('Invoice not found', err_code='not-found', http_status=404)
        # check that PDF is available
        if not invoice['has_pdf']:
            raise APIError('PDF not available', err_code='not-available', http_status=404)

        try:
            result = schemas.get_pdf_facture(
                self, {'PORTAIL': {'GETPDFFACTURE': {'IDFACTURE': int(invoice['display_id'])}}}
            )
        except axel.AxelError as e:
            raise APIError(
                'Axel error: %s' % e,
                err_code='error',
                http_status=404,
                data={'xml_request': e.xml_request, 'xml_response': e.xml_response},
            )
        b64content = base64.b64decode(
            result.json_response['DATA']['PORTAIL']['GETPDFFACTURE']['FACTUREPDF'] or ''
        )
        if not b64content:
            raise APIError('PDF error', err_code='error', http_status=404)
        response = HttpResponse(content_type='application/pdf')
        response['Content-Disposition'] = 'attachment; filename="%s.pdf"' % invoice_id
        response.write(b64content)
        return response

    @endpoint(
        display_category=_('Invoices'),
        display_order=5,
        name='regie',
        methods=['post'],
        perm='can_access',
        pattern=r'^(?P<regie_id>[\w-]+)/invoice/(?P<invoice_id>\w+-\d+)/pay/?$',
        example_pattern='{regie_id}/invoice/{invoice_id}/pay',
        description=_('Notify an invoice as paid'),
        parameters={
            'regie_id': {'description': _('Regie identifier'), 'example_value': 'ENF'},
            'invoice_id': {'description': _('Invoice identifier'), 'example_value': 'IDFAM-42'},
        },
        post={
            'request_body': {
                'schema': {
                    'application/json': schemas.PAYMENT_SCHEMA,
                }
            }
        },
    )
    def pay_invoice(self, request, regie_id, invoice_id, **kwargs):
        family_id, invoice_id = invoice_id.split('-')

        invoice = self.get_invoice(regie_id=regie_id, family_id=family_id, invoice_id=invoice_id)
        if invoice is None:
            raise APIError('Invoice not found', err_code='not-found')

        transaction_amount = invoice['amount']
        post_data = {
            'IDFACTURE': int(invoice_id),
            'IDENTREGIEENC': regie_id,
            'MONTANT': transaction_amount,
            'IDENTMODEREGLEMENT': 'INCB',
        }
        try:
            result = schemas.set_paiement(self, {'PORTAIL': {'SETPAIEMENT': post_data}})
        except axel.AxelError as e:
            raise APIError(
                'Axel error: %s' % e,
                err_code='error',
                data={
                    'xml_request': e.xml_request,
                    'xml_response': e.xml_response,
                    'regie_id': regie_id,
                    'family_id': family_id,
                    'invoice': invoice,
                    'post_data': post_data,
                    'kwargs': kwargs,
                },
            )
        try:
            code = int(result.json_response['DATA']['PORTAIL']['SETPAIEMENT']['CODE'])
        except (KeyError, ValueError):
            raise APIError(
                'Wrong pay-invoice response',
                err_code='pay-invoice-code-response-error',
                data={
                    'regie_id': regie_id,
                    'family_id': family_id,
                    'invoice': invoice,
                    'post_data': post_data,
                    'kwargs': kwargs,
                    'result': result.json_response,
                },
                http_status=500,
            )

        if code < 0:
            raise APIError('Wrong pay-invoice status', err_code='pay-invoice-code-error-%s' % code)

        return {
            'created': True,
            'data': {
                'xml_request': result.xml_request,
                'xml_response': result.xml_response,
            },
        }


class Link(models.Model):
    resource = models.ForeignKey(CaluireAxel, on_delete=models.CASCADE)
    name_id = models.CharField(blank=False, max_length=256)
    family_id = models.CharField(blank=False, max_length=128)
    person_id = models.CharField(blank=False, max_length=128)

    class Meta:
        unique_together = ('resource', 'name_id')
