# passerelle - uniform access to multiple data sources and services
# Copyright (C) 2018 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 collections
import hashlib
import json
import re
import uuid
from datetime import date, datetime, time, timedelta
from urllib import parse as urlparse

from django.contrib.postgres.fields import JSONField
from django.core.cache import cache
from django.db import models, transaction
from django.utils import dateformat, dateparse
from django.utils.encoding import force_bytes
from django.utils.translation import gettext_lazy as _
from requests.exceptions import RequestException

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

DEFAULT_MIN_CAPACITY = 0
DEFAULT_MAX_CAPACITY = 100000


CREATE_RESERVATION_SCHEMA = {
    "$schema": "http://json-schema.org/draft-04/schema#",
    "title": "Planitech createreservation",
    "description": "",
    "type": "object",
    "required": [
        "date",
        "start_time",
        "end_time",
        "place_id",
        "price",
        "name_id",
        "first_name",
        "last_name",
        "email",
        "activity_id",
        "object",
        "type_id",
        "vat_rate",
    ],
    "properties": {
        "date": {
            "description": "Date",
            "type": "string",
        },
        "start_time": {
            "description": "Start time",
            "type": "string",
        },
        "end_time": {
            "description": "End time",
            "type": "string",
        },
        "place_id": {
            "description": "Place identifier",
            "type": "number",
        },
        "price": {
            "description": "Price",
            "type": "number",
        },
        "name_id": {
            "description": "Publik user nameID",
            "type": "string",
        },
        "first_name": {
            "description": "First name",
            "type": "string",
        },
        "last_name": {
            "description": "Last name",
            "type": "string",
        },
        "email": {
            "description": "Email",
            "type": "string",
        },
        "activity_id": {
            "description": "Activity identifier",
            "type": "number",
        },
        "object": {
            "description": "Object",
            "type": "string",
        },
        "type_id": {
            "description": "Rerservation type identifier",
            "type": "number",
        },
        "vat_rate": {
            "description": "VAT rate",
            "type": "number",
        },
        "price_code": {
            "description": "User price code",
            "type": "string",
        },
    },
}


GET_RESERVATION_PRICE_SCHEMA = {
    "$schema": "http://json-schema.org/draft-04/schema#",
    "title": "Planitech getreservationprice",
    "description": "",
    "type": "object",
    "required": [
        "date",
        "start_time",
        "end_time",
        "place_id",
        "name_id",
        "first_name",
        "last_name",
        "email",
        "activity_id",
        "type_id",
    ],
    "properties": {
        "date": {
            "description": "Date",
            "type": "string",
        },
        "start_time": {
            "description": "Start time",
            "type": "string",
        },
        "end_time": {
            "description": "End time",
            "type": "string",
        },
        "place_id": {
            "description": "Place identifier",
            "type": "number",
        },
        "name_id": {
            "description": "Publik user nameID",
            "type": "string",
        },
        "first_name": {
            "description": "First name",
            "type": "string",
        },
        "last_name": {
            "description": "Last name",
            "type": "string",
        },
        "email": {
            "description": "Email",
            "type": "string",
        },
        "activity_id": {
            "description": "Activity identifier",
            "type": "number",
        },
        "type_id": {
            "description": "Rerservation type identifier",
            "type": "number",
        },
        "price_code": {
            "description": "User price code",
            "type": "string",
        },
    },
}


RESERVATION_STATUS = {"confirmed": 3, "invalid": 0, " pre-reservation": 1, "standard": 2}

UPDATE_RESERVATION_SCHEMA = {
    "$schema": "http://json-schema.org/draft-04/schema#",
    "title": "Planitech updatereservation",
    "description": "",
    "type": "object",
    "required": ["reservation_id", "status"],
    "properties": {
        "reservation_id": {
            "description": "Reservation Identifier",
            "type": "number",
        },
        "status": {
            "description": "Status of the reservation",
            "type": "string",
            "enum": list(RESERVATION_STATUS.keys()),
        },
    },
}


def parse_date(date_str):
    date_obj = dateparse.parse_date(date_str)
    if date_obj is None:
        raise APIError("Invalid date format: %s" % date_str)
    return date_obj


def parse_time(time_str):
    timeobj = dateparse.parse_time(time_str)
    if timeobj is None:
        raise APIError("Invalid time format: %s" % time_str)
    return timeobj


def compute_hash(content, hardness, salt):
    sha = hashlib.new('sha512', force_bytes(salt + content))
    for idx in range(hardness):
        sha = hashlib.new('sha512', sha.digest())
    return sha.hexdigest().upper()


def date_to_datetime(date_str):
    date_obj = parse_date(date_str)
    if date_obj is None:
        raise APIError("Invalid date string: %s" % date_str)
    return datetime.combine(date_obj, time(hour=12))


def get_duration(start_time_str, end_time_str):
    start_time = parse_time(start_time_str)
    end_time = parse_time(end_time_str)
    return time_to_minutes(end_time) - time_to_minutes(start_time)


def get_salt(salt):
    return re.match(r'<(.*?)>', salt).groups()[0]


def combine_date_time(date_p, time_str):
    if not isinstance(date_p, date):
        date_p = parse_date(date_p)
    time_obj = parse_time(time_str)
    return datetime.combine(date_p, time_obj)


def time_to_minutes(timeobj):
    return float(timeobj.hour * 60 + timeobj.minute)


def get_extensions(post_data):
    extensions = collections.defaultdict(dict)

    for field_name, field_value in post_data.items():
        if field_name.startswith('extension_'):
            try:
                extension_id, extension_property = field_name[10:].split('_', 1)
            except ValueError:
                raise APIError("Wrong extension format for : '%s'" % field_name)
            extensions[extension_id][extension_property] = field_value

    res = []
    for extension in extensions.values():
        for key in ('name', 'value'):
            if key not in extension:
                raise APIError("Missing '%s' in extension" % key)
        res.append(
            {'name': extension['name'], 'value': extension['value'], 'type': extension.get('type', 'string')}
        )

    return res


class PlanitechConnector(BaseResource):
    url = models.URLField(
        max_length=400,
        verbose_name=_('Planitec API endpoint'),
        help_text=_('URL of the Planitec API endpoint'),
    )
    username = models.CharField(max_length=128, verbose_name=_('Service username'))
    password = models.CharField(max_length=128, verbose_name=_('Service password'), null=True, blank=True)
    verify_cert = models.BooleanField(default=True, verbose_name=_('Check HTTPS Certificate validity'))
    custom_fields = JSONField(_('Custom places fields'), blank=True, null=True)
    price_code = models.CharField(max_length=128, verbose_name=_('Price code'), blank=True)

    category = _('Business Process Connectors')

    class Meta:
        verbose_name = _('Planitec')

    def _call_planitech(self, session_meth, endpoint, params=None):
        if not getattr(self, '_planitech_session', False):
            self._login()
            self._planitech_session = True

        kwargs = {}
        if params is not None:
            kwargs['data'] = json.dumps(mste.encode(params))
        response = session_meth(urlparse.urljoin(self.url, endpoint), **kwargs)
        if response.status_code != 200:
            error_msg = "Planitech error %s" % response.status_code
            try:
                data = mste.decode(response.json())
                if hasattr(data, 'get'):
                    error = data.get('errors')
                    if error:
                        error_msg += " - %s" % error
            except TypeError:
                pass
            raise APIError(error_msg)
        return mste.decode(response.json())

    def _get_places_fields(self):
        if self.custom_fields:
            return self.custom_fields.get('places', [])
        return []

    def _raw_get_places_referential(self, refresh_cache=False):
        cache_key = 'planitech-%s-places' % self.id
        ref = cache.get(cache_key)
        if ref is None or refresh_cache:
            data = self._call_planitech(self.requests.get, 'getPlacesList')
            ref = {}
            for place in data['placesList']:
                place_id = int(place['identifier'])
                ref[place_id] = {'identifier': place_id, 'label': place['label']}

            extensionAttributes = {'capacity': {'name': 'TOTCAP', 'type': 'int'}}

            for custom_field in self._get_places_fields():
                field_name = custom_field['name']
                extensionAttributes[field_name] = {'name': field_name, 'type': custom_field['type']}

            data = self._call_planitech(
                self.requests.post,
                'getPlacesInfo',
                {
                    "placeIdentifiers": [float(key) for key in ref],
                    "extensionAttributes": extensionAttributes,
                },
            )
            for place in data['requestedPlaces']:

                place_id = int(place['identifier'])
                ref[place_id]['street_number'] = place.get('streetNumber')
                ref[place_id]['address'] = place.get('address1')
                ref[place_id]['city'] = place.get('city')
                ref[place_id]['zipcode'] = place.get('zipCode')

                # Custom attributes
                for attr_name, attr_def in extensionAttributes.items():
                    attr_value = place.get(attr_name)
                    if attr_value is not None and attr_def['type'] == 'int':
                        attr_value = int(attr_value)
                    ref[place_id][attr_name] = attr_value

            cache.set(cache_key, ref)
        return ref

    def _get_places_referential(
        self, min_capacity=DEFAULT_MIN_CAPACITY, max_capacity=DEFAULT_MAX_CAPACITY, **kwargs
    ):

        ref = self._raw_get_places_referential()
        res = {}

        try:
            min_capacity, max_capacity = int(min_capacity), int(max_capacity)
        except (ValueError, TypeError):
            raise APIError("min_capacity and max_capacity must be integers")

        for place_id, place_data in ref.items():
            # Filter on capacity
            if min_capacity != DEFAULT_MIN_CAPACITY or max_capacity != DEFAULT_MAX_CAPACITY:
                if place_data.get('capacity') is None:
                    continue
                if place_data['capacity'] < min_capacity or place_data['capacity'] > max_capacity:
                    continue

            # Filter on custom fields
            skip = False
            for filter_name, filter_value in kwargs.items():
                for field in self._get_places_fields():
                    if filter_name == field['name']:
                        if field['type'] == 'int':
                            filter_value = int(filter_value)
                        if place_data.get(filter_name) != filter_value:
                            skip = True
                        break
            if skip:
                continue
            res[place_id] = place_data

        return res

    def _login(self):
        try:
            auth_url = urlparse.urljoin(self.url, 'auth')
            response = self.requests.get(auth_url, headers={'MH-LOGIN': self.username})
            response.raise_for_status()
            part1, salt1, part2, salt2, _ = re.split(r'(\<.*?\>)', response.text)
            hardness1 = int(part1.split(':')[1])
            hardness2 = int(part2.split(':')[1])
            salt1 = get_salt(salt1)
            salt2 = get_salt(salt2)
            tmp_hash = compute_hash(self.password, hardness1, salt1)
            hash_pass = compute_hash(tmp_hash, hardness2, salt2)
            response = self.requests.get(auth_url, headers={'MH-PASSWORD': hash_pass})
            response.raise_for_status()
            # the last response should have set a cookie which will be used for authentication
        except RequestException as e:
            raise APIError("Authentication to Planitec failed: %s" % str(e))

    def update_or_create_user(self, post_data):
        dyn_price_code = post_data.get('price_code')
        price_code = dyn_price_code or self.price_code

        with transaction.atomic():
            pairing, created = Pairing.objects.get_or_create(
                resource=self,
                name_id=post_data['name_id'],
                defaults={'external_id': uuid.uuid4().hex, 'price_code': price_code},
            )
            if created:
                # Create planitec user
                params = {
                    "externalUserIdentifier": pairing.external_id,
                    "name": post_data['last_name'],
                    "firstName": post_data['first_name'],
                    "mail": post_data['email'],
                    "pricingCode": price_code,
                }
                data = self._call_planitech(self.requests.post, 'createPerson', params)
                if data.get('creationStatus') != 'OK':
                    raise APIError("Person creation failed: %s" % data.get('creationStatus'))

            elif dyn_price_code and pairing.price_code != dyn_price_code:
                # Update planitec user
                pairing.price_code = dyn_price_code
                pairing.save()
                params = {'externalUserIdentifier': pairing.external_id, 'pricingCode': dyn_price_code}
                data = self._call_planitech(self.requests.post, 'updatePerson', params)
                if data.get('modificationStatus') != 'OK':
                    raise APIError("Person update failed: %s" % data.get('modificationStatus'))

        return pairing

    @endpoint(
        perm='can_access',
        post={
            'description': _('Get reservation price'),
            'request_body': {'schema': {'application/json': GET_RESERVATION_PRICE_SCHEMA}},
        },
    )
    def getreservationprice(self, request, post_data):
        start_datetime = combine_date_time(post_data['date'], post_data['start_time'])
        end_datetime = combine_date_time(post_data['date'], post_data['end_time'])

        pairing = self.update_or_create_user(post_data)

        params = {
            "activityID": mste.Uint32(post_data['activity_id']),
            "contractorExternalIdentifier": pairing.external_id,
            "end": end_datetime,
            "isWeekly": False,
            "places": [float(post_data['place_id'])],
            "start": start_datetime,
            "typeID": mste.Uint32(post_data['type_id']),
        }
        data = self._call_planitech(self.requests.post, 'getFutureReservationPrice', params)
        if data.get('calculationStatus') != 'OK':
            raise APIError("Get reservation price failed: %s" % data.get('calculationStatus'))
        price = data.get('calculatedPrice', False)
        if price is False:
            raise APIError("Get reservation price failed: no price")
        return {'data': {'price': int(price), 'raw_data': data}}

    @endpoint(
        perm='can_access',
        post={
            'description': _('Create reservation'),
            'request_body': {'schema': {'application/json': CREATE_RESERVATION_SCHEMA}},
        },
    )
    def createreservation(self, request, post_data):
        start_datetime = combine_date_time(post_data['date'], post_data['start_time'])
        end_datetime = combine_date_time(post_data['date'], post_data['end_time'])
        request_date = datetime.now()

        pairing = self.update_or_create_user(post_data)

        params = {
            "activityID": mste.Uint32(post_data['activity_id']),
            "contractorExternalIdentifier": pairing.external_id,
            "end": end_datetime,
            "isWeekly": False,
            "object": post_data['object'],
            "places": [float(post_data['place_id'])],
            "price": mste.Uint32(post_data['price']),
            "requestDate": request_date,
            "start": start_datetime,
            "typeID": mste.Uint32(post_data['type_id']),
            "vatRate": mste.Uint32(post_data['vat_rate']),
        }
        extensions = get_extensions(post_data)
        if extensions:
            params['extensions'] = extensions

        data = self._call_planitech(self.requests.post, 'createReservation', params)
        if data.get('creationStatus') != 'OK':
            raise APIError("Reservation creation failed: %s" % data.get('creationStatus'))
        reservation_id = data.get('reservationIdentifier')
        if not reservation_id:
            raise APIError("Reservation creation failed: no reservation ID")
        return {'data': {'reservation_id': int(reservation_id), 'raw_data': data}}

    def hourly(self):
        self._raw_get_places_referential(refresh_cache=True)

    def _date_display(self, raw_data):
        available_dates = set()
        for place in raw_data.get('availablePlaces', []):
            for freegap in place.get('freeGaps', []):
                available_dates.add(freegap[0].date())
        res = []
        available_dates = list(available_dates)
        available_dates.sort()
        for date_obj in available_dates:
            date_text = dateformat.format(date_obj, 'l d F Y')
            short_text = dateformat.format(date_obj, 'd/m/Y')
            res.append({"id": date_obj.isoformat(), "text": date_text, "short_text": short_text})
        return res

    def _place_display(self, raw_data):
        available_places = []
        for place in raw_data.get('availablePlaces', []):
            available_places.append(int(place['placeIdentifier']))
        places_ref = self._raw_get_places_referential()
        res = []
        for place in available_places:
            res.append({"id": place, "text": places_ref[place]['label']})
        return res

    def _full_display(self, raw_data, places_id):
        places_ref = self._raw_get_places_referential()
        res = {'date': self._date_display(raw_data), 'place': self._place_display(raw_data)}
        all_dates = [d['id'] for d in res['date']]
        full = []
        for place_id in places_id:
            place_data = {'id': place_id, 'text': places_ref[place_id]['label'], 'dates': []}
            place_dates = []
            for place in raw_data.get('availablePlaces', []):
                if place_id == int(place['placeIdentifier']):
                    for freegap in place.get('freeGaps', []):
                        place_dates.append(freegap[0].date().isoformat())
                    break
            for d in all_dates:
                available = d in place_dates
                place_data['dates'].append({'id': d, 'available': available})
            assert len(place_data['dates']) == len(all_dates)
            full.append(place_data)

        res['full'] = full
        return res

    @endpoint(
        description_get=_('Get days available for reservation'),
        methods=['get'],
        perm='can_access',
        parameters={
            'min_capacity': {
                'description': _('Minimum capacity'),
                'example_value': '1',
            },
            'max_capacity': {
                'description': _('Maximum capacity'),
                'example_value': '10',
            },
            'start_date': {
                'description': _('Start date'),
                'type': 'date',
                'example_value': '2018-10-10',
            },
            'start_days': {
                'description': _('Start days'),
                'example_value': '2',
            },
            'end_date': {
                'description': _('End date'),
                'type': 'date',
                'example_value': '2018-12-10',
            },
            'start_time': {
                'description': _('Start time'),
                'example_value': '10:00',
            },
            'end_time': {
                'description': _('End time'),
                'example_value': '18:00',
            },
            'end_days': {
                'description': _('End days'),
                'example_value': '10',
            },
            'weekdays': {
                'description': _(
                    'Week days, comma separated list of integers beetween 0 (sunday) and 6 (saturday)'
                ),
                'example_value': 'true',
                'type': 'string',
            },
            'place_id': {
                'description': _('Place identifier'),
                'example_value': '2',
                'type': 'int',
            },
            'display': {
                'description': _('Display'),
                'example_value': 'date',
            },
        },
    )
    def getfreegaps(
        self,
        request,
        display,
        start_time,
        end_time,
        min_capacity=DEFAULT_MIN_CAPACITY,
        start_date=None,
        start_days=None,
        end_date=None,
        end_days=None,
        max_capacity=DEFAULT_MAX_CAPACITY,
        weekdays=None,
        place_id=None,
        **kwargs,
    ):

        # Additional parameters check
        valid_displays = ['date', 'place', 'full']
        if display not in valid_displays:
            raise APIError("Valid display are: %s" % ", ".join(valid_displays))
        if start_date is None and start_days is None:
            raise APIError("start_date or start_days is required")

        # Starting date computation
        if start_date is not None:
            utc_start_datetime = combine_date_time(start_date, start_time)
        else:
            date_obj = (datetime.now() + timedelta(int(start_days))).date()
            utc_start_datetime = combine_date_time(date_obj, start_time)

        # Ending date computation
        if end_date is None and end_days is None:
            utc_end_datetime = (utc_start_datetime + timedelta(days=1)).replace(hour=0, minute=0)
        elif end_date is not None:
            utc_end_datetime = combine_date_time(end_date, '00:00')
        elif end_days is not None:
            date_obj = (datetime.now() + timedelta(int(end_days))).date()
            utc_end_datetime = combine_date_time(date_obj, '00:00')

        duration = get_duration(start_time, end_time)

        # Places restriction
        if place_id is not None:
            places_id = [int(place_id)]
        else:
            places_id = self._get_places_referential(
                min_capacity=min_capacity, max_capacity=max_capacity, **kwargs
            ).keys()

        params = {
            "placeIdentifiers": [float(p_id) for p_id in places_id],
            "startingDate": utc_start_datetime,
            "endingDate": utc_end_datetime,
            "requestedStartingTime": float(0),
            "requestedEndingTime": duration,
        }

        if weekdays is not None:
            reservation_days = []
            for day in [d.strip() for d in weekdays.split(',') if d.strip()]:
                try:
                    day = mste.Uint32(day)
                    if not 0 <= day <= 6:
                        raise ValueError()
                    reservation_days.append(mste.Uint32(day))
                except (ValueError, TypeError):
                    raise APIError('weekdays must be a comma separated list of integers beetween 0 and 6')
            if reservation_days:
                params['reservationDays'] = reservation_days

        raw_data = self._call_planitech(self.requests.post, 'getFreeGaps', params)

        if display == 'date':
            return {'data': self._date_display(raw_data)}

        if display == 'place':
            return {'data': self._place_display(raw_data)}

        if display == 'full':
            return {'data': self._full_display(raw_data, places_id)}

    def generic_call(self, endpoint, data_key):
        raw_data = self._call_planitech(self.requests.post, endpoint)
        data = raw_data[data_key]
        for item in data:
            if 'identifier' in item:
                item['identifier'] = int(item['identifier'])
        return data

    @endpoint(description_get=_('Get activities'), methods=['get'], perm='can_access')
    def getactivities(self, request):
        return {'data': self.generic_call('getActivities', 'activities')}

    @endpoint(description_get=_('Get activity types'), methods=['get'], perm='can_access')
    def getactivitytypes(self, request):
        return {'data': self.generic_call('getActivityTypes', 'types')}

    @endpoint(description_get=_('Get place'), methods=['get'], perm='can_access')
    def getplace(self, request, id):
        try:
            id_ = int(id)
        except ValueError:
            raise APIError('ID must be an integer')
        ref = self._raw_get_places_referential()
        if id_ not in ref:
            raise APIError('No place with ID %s' % id_)
        return {'data': ref[int(id_)]}

    @endpoint(description_get=_('Get places referential'), methods=['get'], perm='can_access')
    def getplacesreferential(self, request, **kwargs):
        return {'data': self._get_places_referential(**kwargs)}

    @endpoint(description_get=_('Get reservation infos'), methods=['get'], perm='can_access')
    def getreservationsinfo(self, request, reservation_id):
        params = {'reservationIdentifiers': [float(reservation_id)]}
        data = self._call_planitech(self.requests.post, 'getReservationsInfo', params)
        data = data.get('requestedReservations', [])

        res = {}
        # Delete circular references that are not json serializable
        if data:
            res = data[0]
            if 'activity' in res and 'reservations' in res['activity']:
                del res['activity']['reservations']
            if 'contractor' in res and 'reservations' in res['contractor']:
                del res['contractor']['reservations']

        return {'data': res}

    @endpoint(description_get=_('Get reservation types'), methods=['get'], perm='can_access')
    def getreservationtypes(self, request):
        return {'data': self.generic_call('getReservationTypes', 'types')}

    @endpoint(description_get=_('Get users'), methods=['get'], perm='can_access')
    def getusers(self, request):
        return {'data': self.generic_call('getUsersList', 'usersList')}

    @endpoint(
        methods=['post'],
        perm='can_access',
        post={
            'description': _('Update reservation'),
            'request_body': {'schema': {'application/json': UPDATE_RESERVATION_SCHEMA}},
        },
    )
    def updatereservation(self, request, post_data):
        params = {
            "reservationIdentifier": mste.Uint32(post_data['reservation_id']),
            "situation": mste.Uint32(RESERVATION_STATUS[post_data['status']]),
        }
        extensions = get_extensions(post_data)
        if extensions:
            params['extensions'] = extensions

        data = self._call_planitech(self.requests.post, 'updateReservation', params)
        if data.get('modificationStatus') != 'OK':
            raise APIError("Update reservation failed: %s" % data.get('modificationStatus'))
        return {'data': {'raw_data': data}}

    def check_status(self):
        auth_url = urlparse.urljoin(self.url, 'auth')
        response = self.requests.get(auth_url, headers={'MH-LOGIN': self.username})
        response.raise_for_status()


class Pairing(models.Model):
    class Meta:
        unique_together = (
            ('resource', 'name_id'),
            ('resource', 'external_id'),
        )

    resource = models.ForeignKey(PlanitechConnector, on_delete=models.CASCADE)
    name_id = models.CharField(blank=False, max_length=256)
    external_id = models.CharField(blank=False, max_length=256)
    created = models.DateTimeField(auto_now_add=True)
    price_code = models.CharField(max_length=128, verbose_name=_('Price code'), blank=True)
