# passerelle - uniform access to multiple data sources and services
# Copyright (C) 2022  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 hashlib
import re
import xml.etree.ElementTree as ET

import requests
from django.utils.http import urlencode


class BBB:
    CALL_CREATE = 'create'
    CALL_END = 'end'
    CALL_GET_MEETING_INFO = 'getMeetingInfo'
    CALL_GET_MEETINGS = 'getMeetings'
    CALL_IS_MEETING_RUNNING = 'isMeetingRunning'
    CALL_JOIN = 'join'

    PARAM_ATTENDEE_PW = 'attendeePW'
    PARAM_CREATE_TIME = 'createTime'
    PARAM_FULL_NAME = 'fullName'
    PARAM_MEETING_ID = 'meetingID'
    PARAM_MODERATOR_PW = 'moderatorPW'
    PARAM_NAME = 'name'
    PARAM_ROLE = 'role'

    TAG_RETURN_CODE = 'returncode'
    TAG_MESSAGE_KEY = 'messageKey'
    TAG_MESSAGE = 'message'

    RETURN_CODE_SUCCESS = 'SUCCESS'
    RETURN_CODE_FAILED = 'FAILED'
    RETURN_CODES = [RETURN_CODE_SUCCESS, RETURN_CODE_FAILED]

    ROLE_MODERATOR = 'MODERATOR'
    ROLE_VIEWER = 'VIEWER'
    ROLES = [ROLE_MODERATOR, ROLE_VIEWER]

    MESSAGE_KEY_ID_NOT_UNIQUE = 'idNotUnique'
    MESSAGE_KEY_NOT_FOUND = 'notFound'
    MESSAGE_KEY_CHECKSUM_ERROR = 'checksumError'
    MESSAGE_KEY_SENT_END_MEETING_REQUEST = 'sentEndMeetingRequest'

    class BBBError(Exception):
        def __init__(self, message, message_key=None, response=None):
            self.message_key = message_key
            self.response = response
            super().__init__(message)

        def __repr__(self):
            return f'<{self.__class__.__name__} "{self}" message_key={self.message_key}>'

    class InvalidResponseError(BBBError):
        pass

    class FailedError(BBBError):
        pass

    class NotFoundError(FailedError):
        pass

    class IdNotUniqueError(FailedError):
        pass

    class ChecksumError(FailedError):
        pass

    MESSAGE_KEY_TO_CLASS = {
        MESSAGE_KEY_ID_NOT_UNIQUE: IdNotUniqueError,
        MESSAGE_KEY_NOT_FOUND: NotFoundError,
        MESSAGE_KEY_CHECKSUM_ERROR: ChecksumError,
    }

    @staticmethod
    def camel_to_snake(string):
        string = re.sub('(.)([A-Z][a-z]+)', r'\1_\2', string)
        string = re.sub('(.)([0-9]+)', r'\1_\2', string)
        return re.sub('([a-z0-9])([A-Z])', r'\1_\2', string).lower()

    def __init__(self, *, url, shared_secret, session=None):
        if not url.endswith('/'):
            raise self.BBBError(f'expected url ending with a slash, got {url!r}')
        self.url = url
        self.shared_secret = shared_secret
        self.http_session = session or requests.Session()
        self.meetings = self.Meetings(bbb=self)

    def _build_query(self, name, parameters):
        """Method to create valid API query
        to call on BigBlueButton. Because each query
        should have a encrypted checksum based on request Data.
        """
        query = urlencode(parameters)
        prepared = f'{name}{query}{self.shared_secret}'
        checksum = hashlib.sha1(prepared.encode('utf-8')).hexdigest()
        sep = '&' if query else ''
        result = f'{query}{sep}checksum={checksum}'
        return result

    def _http_get(self, url):
        try:
            raw_response = self.http_session.get(url)
            raw_response.raise_for_status()
        except requests.RequestException as e:
            raise self.BBBError('transport error', e)
        return raw_response

    @classmethod
    def _parse_xml_doc(cls, root, data):
        for child in root:
            if len(child) == 0:
                data[child.tag.split('}')[-1]] = child.text
            elif max(len(sub) for sub in child) == 0:
                # subobject case
                data[child.tag] = cls._parse_xml_doc(child, {})
            else:
                # array case
                data[child.tag] = [cls._parse_xml_doc(sub, {}) for sub in child]
        return data

    @classmethod
    def _parse_response(cls, response):
        try:
            root = ET.fromstring(response.content)
            if root.tag != 'response':
                raise ValueError('root tag is not response')
            data = {}
            cls._parse_xml_doc(root, data)
            if cls.TAG_RETURN_CODE not in data:
                raise ValueError(f'expected a return code, got {data!r}')
            return_code = data.get(cls.TAG_RETURN_CODE)
            if return_code not in cls.RETURN_CODES:
                raise ValueError(f'expected a return code, got {data!r}')
            data.pop(cls.TAG_RETURN_CODE)
            return return_code, data
        except Exception as e:
            raise cls.BBBError('invalid response', e)

    def _make_url(self, name, parameters):
        query = self._build_query(name, parameters)
        return f'{self.url}api/{name}?{query}'

    def _api_call(self, name, parameters):
        url = self._make_url(name, parameters)
        raw_response = self._http_get(url)
        return_code, response = self._parse_response(raw_response)
        if return_code == self.RETURN_CODE_FAILED:
            exception_class = self.MESSAGE_KEY_TO_CLASS.get(
                response.get(self.TAG_MESSAGE_KEY), self.FailedError
            )
            raise exception_class(
                message=response.get(self.TAG_MESSAGE, 'NO_MESSAGE'),
                message_key=response.get(self.TAG_MESSAGE_KEY),
                response=response,
            )
        return response

    def create_meeting(self, name, meeting_id, attendee_pw=None, moderator_pw=None, **kwargs):
        parameters = {
            self.PARAM_NAME: name,
            self.PARAM_MEETING_ID: meeting_id,
            self.PARAM_ATTENDEE_PW: (
                attendee_pw
                or hashlib.sha1((self.shared_secret + meeting_id + 'attendee').encode()).hexdigest()
            ),
            self.PARAM_MODERATOR_PW: (
                moderator_pw
                or hashlib.sha1((self.shared_secret + meeting_id + 'moderator').encode()).hexdigest()
            ),
        }
        return self._api_call(self.CALL_CREATE, dict(parameters, **kwargs))

    def get_meeting_info(self, meeting_id):
        parameters = {
            self.PARAM_MEETING_ID: meeting_id,
        }
        return self._api_call(self.CALL_GET_MEETING_INFO, parameters)

    def is_meeting_running(self, meeting_id, doraise=False):
        parameters = {
            self.PARAM_MEETING_ID: meeting_id,
        }
        try:
            response = self._api_call(self.CALL_IS_MEETING_RUNNING, parameters)
            if 'running' not in response:
                raise self.InvalidResponseError(
                    'missing running tag in isMeetingRunning response', response=response
                )
            return response['running'] == 'true'
        except self.NotFoundError:
            if doraise:
                raise
            return False

    def end_meeting(self, meeting_id):
        parameters = {
            self.PARAM_MEETING_ID: meeting_id,
        }
        try:
            response = self._api_call(self.CALL_END, parameters)
        except self.NotFoundError:
            return False
        if response[self.TAG_MESSAGE_KEY] == self.MESSAGE_KEY_SENT_END_MEETING_REQUEST:
            return True
        raise self.BBBError(f'expected notFound or sentEndMeetingRequest, got {response!r}')

    def get_meetings(self):
        return self._api_call(self.CALL_GET_MEETINGS, {})

    def make_join_url(self, full_name, meeting_id, role, create_time=None, **kwargs):
        if role not in self.ROLES:
            raise self.BBBError(f'expected a role value, got {role!r}')
        parameters = dict(kwargs)
        parameters.update(
            {
                self.PARAM_FULL_NAME: full_name,
                self.PARAM_MEETING_ID: meeting_id,
                self.PARAM_ROLE: role,
            }
        )
        if create_time is not None:
            parameters[self.PARAM_CREATE_TIME] = create_time
        return self._make_url(self.CALL_JOIN, parameters)

    class Meeting:
        meeting_name = None
        meeting_id = None

        def __init__(self, bbb: 'BBB', **kwargs):
            self.bbb = bbb
            # came to snake case
            parameters = {BBB.camel_to_snake(key): value for key, value in kwargs.items()}
            self.message = {k: v for k, v in parameters.items() if k.startswith('message')}
            self.meeting_id = parameters.pop('meeting_id')
            self.meeting_name = parameters.pop('meeting_name')
            self.attributes = {k: v for k, v in parameters.items() if not k.startswith('message')}

        def end(self):
            return self.bbb.end_meeting(self.meeting_id)

        def join_url(self, full_name, role, **kwargs):
            return self.bbb.make_join_url(
                full_name=full_name,
                role=role,
                meeting_id=self.meeting_id,
                create_time=self.attributes['create_time'],
                **kwargs,
            )

        def __repr__(self):
            return f'<Meeting name={self.meeting_name} id={self.meeting_id}>'

    class Meetings:
        def __init__(self, bbb: 'BBB'):
            self.bbb = bbb

        def create(self, name, meeting_id, **kwargs):
            return BBB.Meeting(
                self.bbb,
                meeting_name=name,
                **self.bbb.create_meeting(name=name, meeting_id=meeting_id, **kwargs),
            )

        def get(self, meeting_id):
            return BBB.Meeting(self.bbb, **self.bbb.get_meeting_info(meeting_id=meeting_id))

        def all(self):
            response = self.bbb.get_meetings()
            for response_meeting in response.get('meetings', []):
                yield BBB.Meeting(self.bbb, **response_meeting)
