from datetime import datetime  # generate_token
import calendar                # generate_token
import base64                  # generate_token
import random                  # generate_token
import time                    # generate_token
import hmac                    # _sign_string
import hashlib                 # _sign_string
import requests                # create_session, archiving
import json                    # archiving
import platform                # user-agent
from socket import inet_aton   # create_session
import xml.dom.minidom as xmldom # create_session
from jose import jwt           # _create_jwt_auth_header
import random                  # _create_jwt_auth_header

# compat
from six.moves.urllib.parse import urlencode
from six import text_type, u, b, PY3
from enum import Enum

from .version import __version__
from .endpoints import Endpoints
from .session import Session
from .archives import Archive, ArchiveList, OutputModes
from .stream import Stream
from .streamlist import StreamList
from .exceptions import (
    OpenTokException,
    RequestError,
    AuthError,
    NotFoundError,
    ArchiveError,
    SignalingError,
    GetStreamError,
    ForceDisconnectError
)

class Roles(Enum):
    """List of valid roles for a token."""
    subscriber = u('subscriber')
    """A subscriber can only subscribe to streams."""
    publisher =  u('publisher')
    """A publisher can publish streams, subscribe to streams, and signal"""
    moderator =  u('moderator')
    """In addition to the privileges granted to a publisher, in clients using the OpenTok.js 2.2
    library, a moderator can call the `forceUnpublish()` and `forceDisconnect()` method of the
    Session object.
    """

class MediaModes(Enum):
    """List of valid settings for the mediaMode parameter of the OpenTok.create_session() method."""
    routed = u('disabled')
    """The session will transmit streams using the OpenTok Media Server."""
    relayed = u('enabled')
    """The session will attempt to transmit streams directly between clients. If two clients
    cannot send and receive each others' streams, due to firewalls on the clients' networks,
    their streams will be relayed using the OpenTok TURN Server."""

class ArchiveModes(Enum):
    """List of valid settings for the archive_mode parameter of the OpenTok.create_Session()
    method."""
    manual = u('manual')
    """The session will be manually archived."""
    always = u('always')
    """The session will be automatically archived."""

class OpenTok(object):
    """Use this SDK to create tokens and interface with the server-side portion
    of the Opentok API.
    """
    TOKEN_SENTINEL = 'T1=='
    """For internal use."""

    def __init__(self, api_key, api_secret, api_url='https://api.opentok.com', timeout=None):
        self.api_key = str(api_key)
        self.api_secret = api_secret
        self.timeout = timeout
        self._proxies = None
        self.endpoints = Endpoints(api_url, self.api_key)

    @property
    def proxies(self):
        return self._proxies

    @proxies.setter
    def proxies(self, proxies):
        self._proxies = proxies

    def generate_token(self, session_id, role=Roles.publisher, expire_time=None, data=None,
        initial_layout_class_list=[]):
        """
        Generates a token for a given session.

        :param String session_id: The session ID of the session to be accessed by the client using
          the token.

        :param String role: The role for the token. Valid values are defined in the Role
          class:

          * `Roles.subscriber` -- A subscriber can only subscribe to streams.

          * `Roles.publisher` -- A publisher can publish streams, subscribe to
            streams, and signal. (This is the default value if you do not specify a role.)

          * `Roles.moderator` -- In addition to the privileges granted to a
            publisher, in clients using the OpenTok.js 2.2 library, a moderator can call the
            `forceUnpublish()` and `forceDisconnect()` method of the
            Session object.

        :param int expire_time: The expiration time of the token, in seconds since the UNIX epoch.
          The maximum expiration time is 30 days after the creation time. The default expiration
          time is 24 hours after the token creation time.

        :param String data: A string containing connection metadata describing the
          end-user. For example, you can pass the user ID, name, or other data describing the
          end-user. The length of the string is limited to 1000 characters. This data cannot be
          updated once it is set.

        :param list initial_layout_class_list: An array of class names (strings)
          to be used as the initial layout classes for streams published by the client. Layout
          classes are used in customizing the layout of videos in
          `live streaming broadcasts <https://tokbox.com/developer/guides/broadcast/#live-streaming>`_ and
          `composed archives <https://tokbox.com/developer/guides/archiving/layout-control.html>`_

        :rtype:
          The token string.
        """

        # normalize
        # expire_time can be an integer, a datetime object, or anything else that can be coerced into an integer
        # after this block it will only be an integer
        if expire_time is not None:
            if isinstance(expire_time, datetime):
                expire_time = calendar.timegm(expire_time.utctimetuple())
            else:
                try:
                    expire_time = int(expire_time)
                except (ValueError, TypeError):
                    raise OpenTokException(u('Cannot generate token, invalid expire time {0}').format(expire_time))
        else:
            expire_time = int(time.time()) + (60*60*24) # 1 day

        # validations
        if not text_type(session_id):
            raise OpenTokException(u('Cannot generate token, session_id was not valid {0}').format(session_id))
        if not isinstance(role, Roles):
            raise OpenTokException(u('Cannot generate token, {0} is not a valid role').format(role))
        now = int(time.time())
        if expire_time < now:
            raise OpenTokException(u('Cannot generate token, expire_time is not in the future {0}').format(expire_time))
        if expire_time > now + (60*60*24*30):  # 30 days
            raise OpenTokException(u('Cannot generate token, expire_time is not in the next 30 days {0}').format(expire_time))
        if data and len(data) > 1000:
            raise OpenTokException(u('Cannot generate token, data must be less than 1000 characters'))
        if initial_layout_class_list and not all(text_type(c) for c in initial_layout_class_list):
            raise OpenTokException(u('Cannot generate token, all items in initial_layout_class_list must be strings'))
        initial_layout_class_list_serialized = u(' ').join(initial_layout_class_list)
        if len(initial_layout_class_list_serialized) > 1000:
            raise OpenTokException(u('Cannot generate token, initial_layout_class_list must be less than 1000 characters'))

        # decode session id to verify api_key
        sub_session_id = session_id[2:]
        sub_session_id_bytes = sub_session_id.encode('utf-8')
        sub_session_id_bytes_padded = sub_session_id_bytes + (b('=') * (-len(sub_session_id_bytes) % 4))
        try:
            decoded_session_id = base64.b64decode(sub_session_id_bytes_padded, b('-_'))
            parts = decoded_session_id.decode('utf-8').split(u('~'))
        except Exception as e:
            raise OpenTokException(u('Cannot generate token, the session_id {0} was not valid').format(session_id))
        if self.api_key not in parts:
            raise OpenTokException(u('Cannot generate token, the session_id {0} does not belong to the api_key {1}').format(session_id, self.api_key))

        data_params = dict(
            session_id                = session_id,
            create_time               = now,
            expire_time               = expire_time,
            role                      = role.value,
            nonce                     = random.randint(0,999999),
            initial_layout_class_list = initial_layout_class_list_serialized
        )
        if data:
            data_params['connection_data'] = data
        data_string = urlencode(data_params, True)

        sig = self._sign_string(data_string, self.api_secret)
        decoded_base64_bytes = u('partner_id={api_key}&sig={sig}:{payload}').format(
            api_key = self.api_key,
            sig     = sig,
            payload = data_string
        )
        if PY3:
            decoded_base64_bytes = decoded_base64_bytes.encode('utf-8')
        token = u('{sentinal}{base64_data}').format(
            sentinal    = self.TOKEN_SENTINEL,
            base64_data = base64.b64encode(decoded_base64_bytes).decode()
        )
        return token

    def create_session(self, location=None, media_mode=MediaModes.relayed, archive_mode=ArchiveModes.manual):
        """
        Creates a new OpenTok session and returns the session ID, which uniquely identifies
        the session.

        For example, when using the OpenTok JavaScript library, use the session ID when calling the
        OT.initSession() method (to initialize an OpenTok session).

        OpenTok sessions do not expire. However, authentication tokens do expire (see the
        generateToken() method). Also note that sessions cannot explicitly be destroyed.

        A session ID string can be up to 255 characters long.

        Calling this method results in an OpenTokException in the event of an error.
        Check the error message for details.

        You can also create a session using the OpenTok
        `REST API <https://tokbox.com/opentok/api/#session_id_production>`_ or
        `the OpenTok dashboard <https://dashboard.tokbox.com/projects>`_.

        :param String media_mode: Determines whether the session will transmit streams using the
             OpenTok Media Router (MediaMode.routed) or not (MediaMode.relayed). By default,
             the setting is MediaMode.relayed.

             With the media_mode property set to MediaMode.relayed, the session
             will attempt to transmit streams directly between clients. If clients cannot connect
             due to firewall restrictions, the session uses the OpenTok TURN server to relay
             audio-video streams.

             The `OpenTok Media
             Router <https://tokbox.com/opentok/tutorials/create-session/#media-mode>`_
             provides the following benefits:

               * The OpenTok Media Router can decrease bandwidth usage in multiparty sessions.
                   (When the mediaMode property is set to  MediaMode.relayed, each client must send
                   a separate audio-video stream to each client subscribing to it.)

               * The OpenTok Media Router can improve the quality of the user experience through
                 audio fallback and video recovery (see https://tokbox.com/platform/fallback). With
                 these features, if a client's connectivity degrades to a degree that
                 it does not support video for a stream it's subscribing to, the video is dropped on
                 that client (without affecting other clients), and the client receives audio only.
                 If the client's connectivity improves, the video returns.

               * The OpenTok Media Router supports the archiving feature, which lets
                 you record, save, and retrieve OpenTok sessions (see http://tokbox.com/platform/archiving).

        :param String archive_mode: Whether the session is automatically archived
            (ArchiveModes.always) or not (ArchiveModes.manual). By default,
            the setting is ArchiveModes.manual, and you must call the
            start_archive() method of the OpenTok object to start archiving. To archive the session
            (either automatically or not), you must set the media_mode parameter to
            MediaModes.routed.

        :param String location: An IP address that the OpenTok servers will use to
            situate the session in its global network. If you do not set a location hint,
            the OpenTok servers will be based on the first client connecting to the session.

        :rtype: The Session object. The session_id property of the object is the session ID.
        """

        # build options
        options = {}
        if not isinstance(media_mode, MediaModes):
            raise OpenTokException(u('Cannot create session, {0} is not a valid media mode').format(media_mode))
        if not isinstance(archive_mode, ArchiveModes):
            raise OpenTokException(u('Cannot create session, {0} is not a valid archive mode').format(archive_mode))
        if archive_mode == ArchiveModes.always and media_mode != MediaModes.routed:
            raise OpenTokException(u('A session with always archive mode must also have the routed media mode.'))
        options[u('p2p.preference')] = media_mode.value
        options[u('archiveMode')] = archive_mode.value
        if location:
            # validate IP address
            try:
                inet_aton(location)
            except:
                raise OpenTokException(u('Cannot create session. Location must be either None or a valid IPv4 address {0}').format(location))
            options[u('location')] = location

        try:
            response = requests.post(self.endpoints.session_url(), data=options, headers=self.headers(), proxies=self.proxies, timeout=self.timeout)
            response.encoding = 'utf-8'

            if response.status_code == 403:
                raise AuthError('Failed to create session, invalid credentials')
            if not response.content:
                raise RequestError()
            dom = xmldom.parseString(response.content)
        except Exception as e:
            raise RequestError('Failed to create session: %s' % str(e))

        try:
            error = dom.getElementsByTagName('error')
            if error:
                error = error[0]
                raise AuthError('Failed to create session (code=%s): %s' % (error.attributes['code'].value, error.firstChild.attributes['message'].value))

            session_id = dom.getElementsByTagName('session_id')[0].childNodes[0].nodeValue
            return Session(self, session_id, location=location, media_mode=media_mode, archive_mode=archive_mode)
        except Exception as e:
            raise OpenTokException('Failed to generate session: %s' % str(e))

    def headers(self):
        """For internal use."""
        return {
            'User-Agent': 'OpenTok-Python-SDK/' + __version__ + ' ' + platform.python_version(),
            'X-OPENTOK-AUTH': self._create_jwt_auth_header()
        }

    def json_headers(self):
        """For internal use."""
        result = self.headers()
        result['Content-Type'] = 'application/json'
        return result

    def start_archive(self, session_id, has_audio=True, has_video=True, name=None, output_mode=OutputModes.composed, resolution=None):
        """
        Starts archiving an OpenTok session.

        Clients must be actively connected to the OpenTok session for you to successfully start
        recording an archive.

        You can only record one archive at a time for a given session. You can only record archives
        of sessions that use the OpenTok Media Router (sessions with the media mode set to routed);
        you cannot archive sessions with the media mode set to relayed.

        For more information on archiving, see the
        `OpenTok archiving <https://tokbox.com/opentok/tutorials/archiving/>`_ programming guide.

        :param String session_id: The session ID of the OpenTok session to archive.
        :param String name: This is the name of the archive. You can use this name
          to identify the archive. It is a property of the Archive object, and it is a property
          of archive-related events in the OpenTok.js library.
        :param Boolean has_audio: if set to True, an audio track will be inserted to the archive.
          has_audio is an optional parameter that is set to True by default. If you set both
          has_audio and has_video to False, the call to the start_archive() method results in
          an error.
        :param Boolean has_video: if set to True, a video track will be inserted to the archive.
          has_video is an optional parameter that is set to True by default.
        :param OutputModes output_mode: Whether all streams in the archive are recorded
          to a single file (OutputModes.composed, the default) or to individual files
          (OutputModes.individual).
        :param String resolution (Optional): The resolution of the archive, either "640x480" (the default)
          or "1280x720". This parameter only applies to composed archives. If you set this
          parameter and set the output_mode parameter to OutputModes.individual, the call to the
          start_archive() method results in an error.

        :rtype: The Archive object, which includes properties defining the archive,
          including the archive ID.
        """
        if not isinstance(output_mode, OutputModes):
            raise OpenTokException(u('Cannot start archive, {0} is not a valid output mode').format(output_mode))


        if resolution and output_mode == OutputModes.individual:
            raise OpenTokException(u('Invalid parameters: Resolution cannot be supplied for individual output mode.'))

        payload = {'name': name,
                   'sessionId': session_id,
                   'hasAudio': has_audio,
                   'hasVideo': has_video,
                   'outputMode': output_mode.value,
                   'resolution': resolution,
        }

        response = requests.post(self.endpoints.archive_url(), data=json.dumps(payload), headers=self.json_headers(), proxies=self.proxies, timeout=self.timeout)

        if response.status_code < 300:
            return Archive(self, response.json())
        elif response.status_code == 403:
            raise AuthError()
        elif response.status_code == 400:
            """
            The HTTP response has a 400 status code in the following cases:
            You do not pass in a session ID or you pass in an invalid session ID.
            No clients are actively connected to the OpenTok session.
            You specify an invalid resolution value.
            The outputMode property is set to "individual" and you set the resolution property and (which is not supported in individual stream archives).
            """
            raise RequestError(response.json().get("message"))
        elif response.status_code == 404:
            raise NotFoundError("Session not found")
        elif response.status_code == 409:
            raise ArchiveError(response.json().get("message"))
        else:
            raise RequestError("An unexpected error occurred", response.status_code)

    def stop_archive(self, archive_id):
        """
        Stops an OpenTok archive that is being recorded.

        Archives automatically stop recording after 90 minutes or when all clients have disconnected
        from the session being archived.

        @param [String] archive_id The archive ID of the archive you want to stop recording.

        :rtype: The Archive object corresponding to the archive being stopped.
        """
        response = requests.post(self.endpoints.archive_url(archive_id) + '/stop', headers=self.json_headers(), proxies=self.proxies, timeout=self.timeout)

        if response.status_code < 300:
            return Archive(self, response.json())
        elif response.status_code == 403:
            raise AuthError()
        elif response.status_code == 404:
            raise NotFoundError("Archive not found")
        elif response.status_code == 409:
            raise ArchiveError("Archive is not in started state")
        else:
            raise RequestError("An unexpected error occurred", response.status_code)

    def delete_archive(self, archive_id):
        """
        Deletes an OpenTok archive.

        You can only delete an archive which has a status of "available" or "uploaded". Deleting an
        archive removes its record from the list of archives. For an "available" archive, it also
        removes the archive file, making it unavailable for download.

        :param String archive_id: The archive ID of the archive to be deleted.
        """
        response = requests.delete(self.endpoints.archive_url(archive_id), headers=self.json_headers(), proxies=self.proxies, timeout=self.timeout)

        if response.status_code < 300:
            pass
        elif response.status_code == 403:
            raise AuthError()
        elif response.status_code == 404:
            raise NotFoundError("Archive not found")
        else:
            raise RequestError("An unexpected error occurred", response.status_code)

    def get_archive(self, archive_id):
        """Gets an Archive object for the given archive ID.

        :param String archive_id: The archive ID.

        :rtype: The Archive object.
        """
        response = requests.get(self.endpoints.archive_url(archive_id), headers=self.json_headers(), proxies=self.proxies, timeout=self.timeout)

        if response.status_code < 300:
            return Archive(self, response.json())
        elif response.status_code == 403:
            raise AuthError()
        elif response.status_code == 404:
            raise NotFoundError("Archive not found")
        else:
            raise RequestError("An unexpected error occurred", response.status_code)

    def get_archives(self, offset=None, count=None):
        """Returns an ArchiveList, which is an array of archives that are completed and in-progress,
        for your API key.

        :param int: offset Optional. The index offset of the first archive. 0 is offset
          of the most recently started archive. 1 is the offset of the archive that started prior to
          the most recent archive. If you do not specify an offset, 0 is used.
        :param int: count Optional. The number of archives to be returned. The maximum
          number of archives returned is 1000.

        :rtype: An ArchiveList object, which is an array of Archive objects.
        """
        params = {}
        if offset is not None:
            params['offset'] = offset
        if count is not None:
            params['count'] = count

        response = requests.get(self.endpoints.archive_url() + "?" + urlencode(params), headers=self.json_headers(), proxies=self.proxies, timeout=self.timeout)

        if response.status_code < 300:
            return ArchiveList(self, response.json())
        elif response.status_code == 403:
            raise AuthError()
        elif response.status_code == 404:
            raise NotFoundError("Archive not found")
        else:
            raise RequestError("An unexpected error occurred", response.status_code)

    def signal(self, session_id, payload, connection_id=None):
        """
        Send signals to all participants in an active OpenTok session or to a specific client
        connected to that session.

        :param String session_id: The session ID of the OpenTok session that receives the signal

        :param Dictionary payload: Structure that contains both the type and data fields. These
        correspond to the type and data parameters passed in the client signal received handlers

        :param String connection_id: The connection_id parameter is an optional string used to
        specify the connection ID of a client connected to the session. If you specify this value,
        the signal is sent to the specified client. Otherwise, the signal is sent to all clients
        connected to the session
        """
        response = requests.post(
            self.endpoints.signaling_url(session_id, connection_id),
            data=json.dumps(payload),
            headers=self.json_headers(),
            proxies=self.proxies,
            timeout=self.timeout
        )

        if response.status_code == 204:
            pass
        elif response.status_code == 400:
            raise SignalingError('One of the signal properties - data, type, sessionId or connectionId - is invalid.')
        elif response.status_code == 403:
            raise AuthError('You are not authorized to send the signal. Check your authentication credentials.')
        elif response.status_code == 404:
            raise SignalingError('The client specified by the connectionId property is not connected to the session.')
        elif response.status_code == 413:
            raise SignalingError('The type string exceeds the maximum length (128 bytes), or the data string exceeds the maximum size (8 kB).')
        else:
            raise RequestError('An unexpected error occurred', response.status_code)

    def get_stream(self, session_id, stream_id):
        """
        Returns an Stream object that contains information of an OpenTok stream:
        
        -id: The stream ID
        -videoType: "camera" or "screen"
        -name: The stream name (if one was set when the client published the stream)
        -layoutClassList: It's an array of the layout classes for the stream
        """
        endpoint = self.endpoints.get_stream_url(session_id, stream_id)
        response = requests.get(
            endpoint, headers=self.json_headers(), proxies=self.proxies, timeout=self.timeout
        )

        if response.status_code == 200:
            return Stream(response.json())
        elif response.status_code == 400:
            raise GetStreamError('Invalid request. This response may indicate that data in your request data is invalid JSON. Or it may indicate that you do not pass in a session ID or you passed in an invalid stream ID.')
        elif response.status_code == 403:
            raise AuthError('You passed in an invalid OpenTok API key or JWT token.')
        elif response.status_code == 408:
            raise GetStreamError('You passed in an invalid stream ID.')
        else:
            raise RequestError('An unexpected error occurred', response.status_code)

    def list_streams(self, session_id):
        """
        Returns a list of Stream objects that contains information of all
        the streams in a OpenTok session, with the following attributes:

        -count: An integer that indicates the number of streams in the session
        -items: List of the Stream objects
        """
        endpoint = self.endpoints.get_stream_url(session_id)

        response = requests.get(
            endpoint, headers=self.json_headers(), proxies=self.proxies, timeout=self.timeout
        )

        if response.status_code == 200:
            return StreamList(response.json())
        elif response.status_code == 400:
            raise GetStreamError('Invalid request. This response may indicate that data in your request data is invalid JSON. Or it may indicate that you do not pass in a session ID or you passed in an invalid stream ID.')
        elif response.status_code == 403:
            raise AuthError('You passed in an invalid OpenTok API key or JWT token.')
        else:
            raise RequestError('An unexpected error occurred', response.status_code)

    def force_disconnect(self, session_id, connection_id):
        """
        Sends a request to disconnect a client from an OpenTok session

        :param String session_id: The session ID of the OpenTok session from which the
        client will be disconnected

        :param String connection_id: The connection ID of the client that will be disconnected
        """
        endpoint = self.endpoints.force_disconnect_url(session_id, connection_id)
        response = requests.delete(
            endpoint, headers=self.json_headers(), proxies=self.proxies, timeout=self.timeout
        )

        if response.status_code == 204:
            pass
        elif response.status_code == 400:
            raise ForceDisconnectError('One of the arguments - sessionId or connectionId - is invalid.')
        elif response.status_code == 403:
            raise AuthError('You are not authorized to forceDisconnect, check your authentication credentials.')
        elif response.status_code == 404:
            raise ForceDisconnectError('The client specified by the connectionId property is not connected to the session.')
        else:
            raise RequestError('An unexpected error occurred', response.status_code)

    def _sign_string(self, string, secret):
        return hmac.new(secret.encode('utf-8'), string.encode('utf-8'), hashlib.sha1).hexdigest()

    def _create_jwt_auth_header(self):
        payload = {
                      'ist': 'project',
                      'iss': self.api_key,
                      'iat': int(time.time()), # current time in unix time (seconds)
                      'exp': int(time.time()) + (60*3), # 3 minutes in the future (seconds)
                      'jti': '{0}'.format(0, random.random())
                  }

        return jwt.encode(payload, self.api_secret, algorithm='HS256')
