from __future__ import print_function
import os
import sys
import requests
import json
import endpoints
from datetime import datetime
import dateutil
from exceptions import APIException, HTTPException
from util import is_sequence

USER_AGENT = 'appdotnet/0.1.3 (Python/%s)' % '.'.join([str(x) for x in
                                                       sys.version_info])


def _successful(response):
    """ Returns whether a response was considered successful. If no body is
    available or the 'meta' dict in the response envelope doesn't contain a
    'code' value, checks the HTTP response code instead.

    :param requests.Response response: a response object
    :returns: (boolean) True if successful

    """
    code = response.status_code

    try:
        code = response.json()['meta']['code']
    except Exception:
        pass

    return code in (200, 201, 202)


def _params(param_dict=None, collapse=False):
    """ Given a dict of parameter key/value pairs, filter out the ones
    whose values are None, and comma-join any parameters with lists/tuples.

    :param dict param_dict: the full set of parameters
    :param boolean collapse: if True, collapses lists/tuples to comma-separated
        lists
    :returns: (dict) the refined set of params

    """
    param_dict = param_dict or {}
    params = dict(filter(lambda (k, v): v is not None, param_dict.iteritems()))

    if collapse:
        for key in params:
            if is_sequence(params[key]):
                params[key] = ','.join(params[key])

    return params


def _raise_errors(response):
    """ Checks the requests.Response object body for any API-level errors, then
    checks whether we received a 4xx- or 5xx-level response code. Raises an
    exception for any undesirables. """

    api_error = None
    http_error = 400 <= response.status_code

    try:
        body = response.json()
        api_error = (body.get('error', False) or
                     body.get('meta', {}).get('error_message'))
    except Exception:
        pass

    if api_error:
        raise APIException(api_error)
    elif http_error:
        raise HTTPException("HTTP %d: %s" % (response.status_code,
                                             response.text))


class Client(object):
    """ An App.net client object. """

    def __init__(self, client_id=None, client_secret=None, app_token=None,
                 user_token=None):
        """ Initialize an API client with the provided credentials.

        :param client_id: (optional) the application's Client ID
        :param client_secret: (optional) the application's Client Secret
        :param app_token: (optional) an application-level token as generated by
            https://account.app.net/oauth/access_token
        :param user_token: (optional) a user-level token

        """
        self.client_id = client_id
        self.client_secret = client_secret
        self.app_token = app_token
        self.user_token = user_token

        self._load_env_credentials()

        self.session = requests.Session()
        self._build_headers()

    def _load_env_credentials(self):
        """ Attempt to load client ID, secret, app token, and user token from
        the environment if not explicitly passed in to the class. """

        if not self.client_id:
            self.client_id = os.environ.get('ADN_CLIENT_ID', None)
        if not self.client_secret:
            self.client_secret = os.environ.get('ADN_CLIENT_SECRET', None)
        if not self.app_token:
            self.app_token = os.environ.get('ADN_APP_TOKEN', None)
        if not self.user_token:
            self.user_token = os.environ.get('ADN_USER_TOKEN', None)

    def _build_headers(self):
        """ Builds the basic set of headers for the session. """
        self.session.headers['User-Agent'] = USER_AGENT
        self.session.headers['Content-Type'] = 'application/json'
        self.session.headers['Accept'] = 'application/json'

        if self.app_token:
            self.session.headers['Authorization'] = 'BEARER ' + self.app_token

    def _build_request(self, key, uri_vars=None, clean=False):
        """ Locate the appropriate endpoint URI and HTTP VERB for the specified
        request key, and return a tuple containing the callable from our
        Session corresponding to that verb, and the full endpoint URL.

        :param key: the endpoint key corresponding to an API method
        :param dict vars: key/value variable pairs in the URI to be substituted
        :returns: (tuple) the HTTP verb and endpoint to use for the request

        """
        source = requests if clean else self.session
        verb, endpoint = endpoints.find_method(key, uri_vars=uri_vars)
        verb = verb.lower()

        if not hasattr(source, verb):
            raise Exception('Cannot find verb method %s in requests!' % verb)

        return (getattr(source, verb), endpoint)

    def _request(self, key, uri_vars=None, params=None, body=None, clean=False,
                 **kwargs):
        """ Issue a request for the endpoint key, substituting in the URL any
        variables provided in `vars`, including any query string or body
        parameters in `params`, and optionally with no authentication
        information if `clean` is True.

        :param key: the endpoint key corresponding to an API method
        :param dict uri_vars: (optional) key/value variable pairs to substitute
            in the URI
        :param dict params: (optional) key/value parameters to include in the
            query string on requests or on POSTs where no other body is
            specified
        :param body: (optional) a JSON-serializable value to use as the body of
            a POST or PUT request
        :param boolean clean: (optional) if True, use a clean request (e.g., no
            authentication headers)
        :param **kwargs: (optional) additional arguments to pass in to the
            requests method
        :returns: (dict|string) the deserialized body, or the original body as
            a str if it could not be deserialized

        """
        body = json.dumps(body) if body is not None else body
        method, endpoint = self._build_request(key, uri_vars=uri_vars,
                                               clean=clean)
        response = method(endpoint, params=params, data=body, **kwargs)
        _raise_errors(response)

        body = response.text
        try:
            body = response.json()
        except Exception:
            pass

        return body

    def create_app_token(self):
        """ Creates an application access token. In order to request an
        application token, you must provide the client ID and client secret
        when you initialize the client.

        :returns: (string) the application access token
        """
        if not self.client_id or not self.client_secret:
            raise Exception('client_id and client_secret must be provided to '
                            'create an app access token.')

        data = {'client_id': self.client_id,
                'client_secret': self.client_secret,
                'grant_type': 'client_credentials'}

        body = self._request('oauth.token.create', clean=True, data=data)
        return body['access_token']

    def stream(self, stream_url, decode=True):
        """ A generator used to iterate over events received via the streaming
        API. Automatic JSON decoding is the default behavior but can be
        disabled.

        Yields an Event instance for each line received via the API. If
        decoding is off, yields a string of the raw line received.

        :param stream_url: the stream endpoint URL
        :param boolean decode: whether to decode the JSON structure before
            yielding.
        :returns: (appdotnet.api.Event) yields an Event instance for each
            streaming API event received

        """
        resp = requests.get(stream_url, stream=True)
        for line in resp.iter_lines(chunk_size=1):
            if not line:
                continue

            value = None

            if not decode:
                value = line
            else:
                try:
                    value = Event(json.loads(line))
                except Exception as ex:
                    print('Cannot decode line: %s (%s)' % (ex, line))

            yield value

    def stream_list(self):
        """ Get a list of streams for the application token.

        :returns: (list) a list of stream dicts, or an empty list if none
        """

        return self._request('streams.list').get('data', [])

    def stream_find(self, key_or_id):
        """ Find a specific stream whose key or ID matches the key_or_id
        parameter.

        :param string key_or_id: the key or ID to search for.
        :returns: (dict|None) a dict representing the requested stream, or None
            if it could not be found

        """
        key_or_id = str(key_or_id)
        for stream in self.stream_list():
            if key_or_id in (stream.get('id', ''), stream.get('key', '')):
                return stream
        return None

    def stream_create(self, key=None, type_list=None, filter_id=None,
                      stream_type='long_poll'):
        """ Creates a stream for the application token with the specified
        options.

        :param key: (optional) the key or name for this stream
        :param list type_list: any combination of object types; see
            http://developers.app.net/docs/resources/stream/
            for a full list
        :param filter_id: (optional) an existing filter ID to apply to this
            stream
        :param stream_type: the stream type; currently long_poll is the only
            type
        :returns: (dict) the response as received from the API

        """
        type_list = type_list or []
        body = _params({'key': key,
                        'object_types': type_list,
                        'type': stream_type,
                        'filter_id': filter_id})
        return self._request('streams.create', body=body)

    def stream_delete(self, stream_id):
        """ Deletes a stream with the specified ID.

        :param integer id: the stream ID to delete
        :returns: (boolean) True if the stream was successfully deleted

        """
        self._request('streams.delete', uri_vars={'stream_id': stream_id})
        return True


class Event(object):
    """ Represents a streaming API event. """

    def __init__(self, event):
        """ Accepts a dict called `event` and provides a positively terrific
        variety of convenience methods to interpret various things about it.

        :param dict event: the original JSON-decoded entity

        """
        self._event = event
        self._meta = self._event.get('meta', {})
        self._data = self._event.get('data', {})

        self._type = self._meta.get('type', None)

    def __hasattr__(self, key):
        return hasattr(self._data, key)

    def __getattr__(self, key):
        return getattr(self._data, key)

    def __contains__(self, key):
        return key in self._data

    def __getitem__(self, key):
        if not key in self._data:
            raise IndexError
        return self._data[key]

    def type(self):
        """ Original meta type of this event. """
        return self._type

    def meta(self, key, default=None):
        """ Returns a value from the meta dict or a default value if the key
        was not present.

        :param key: a dict key from the original meta dict
        :param default: (optional) a default value to return if the key does
            not exist

        """
        return self._meta.get(key, default)

    def data(self, key, default=None):
        """ Returns a value from the data dict or a default value if the key
        was not present.

        :param key: a dict key from the original data dict
        :param default: (optional) a default value to return if the key does
            not exist

        """
        return self._data.get(key, default)

    def _data_entity_id(self, key, default=None):
        """ Returns the `id` value of the specified key in the data dict, or a
        default value if the key was not present.

        :param key: a dict key from the original data dict
        :param default: (optional) a default value to return if the key does
            not exist

        """
        return self._data.get(key, {}).get('id', default)

    def id(self):
        """ Returns the ID of the meta payload, if present. """
        return self._meta.get('id', None)

    def timestamp(self):
        """ Returns the timestamp portion of the meta payload, if present. """
        return float(self._meta.get('timestamp', 0)) / 1000

    def datetime(self):
        """ Returns a datetime object of the `timestamp` value in the meta
        payload, if present. """
        return datetime.fromtimestamp(self.timestamp())

    def create_date(self):
        """ Returns a datetime object of the `created_at` item in the data
        payload, if present. """
        return dateutil.parser.parse(self._data.get('created_at', None))

    def user(self, default=None):
        """ Returns the user dict from the data dict, or a default value. """
        return self._data.get('user', default)

    def user_id(self, default=None):
        """ Returns the user id from the data dict, or a default value. """
        return self._data_entity_id('user', default)

    def user_name(self, default=None):
        """ Returns the user name from the data dict, or a default value. """
        return self.user({}).get('username', default)

    def followed_user(self, default=None):
        """ Returns the followed user from the data dict, or a default value.
        """
        return self._data.get('follows_user', default)

    def followed_user_id(self, default=None):
        """ Returns the followed user id from the data dict, or a default
        value. """
        return self._data_entity_id('follows_user', default)

    def followed_user_name(self, default=None):
        """ Returns the followed user name from the data dict, or a default
        value. """
        return self.followed_user({}).get('username', default)

    def post_id(self, default=None):
        """ Returns the post ID specified as the 'post' target in the data
        dict, or a default value. """
        return self._data_entity_id('post', default)

    def post_user(self, default=None):
        """ Returns the user dict on the 'post' target in the data dict, or a
        default value. """
        return self._data.get('post', {}).get('user', default)

    def post_user_id(self, default=None):
        """ Returns the user id on the 'post' target in the data dict, or a
        default value. """
        return self.post_user({}).get('id', default)

    def post_user_name(self, default=None):
        """ Returns the user name on the 'post' target in the data dict, or a
        default value. """
        return self.post_user({}).get('username', default)

    def reposted_id(self, default=None):
        """ Returns the reposted post ID, or a default value. """
        return self._data_entity_id('repost_of', default)

    def reposted_user(self, default=None):
        """ Returns the reposted user dict, or a default value. """
        return self._data.get('repost_of', {}).get('user', default)

    def reposted_user_id(self, default=None):
        """ Returns the reposted user's ID, or a default value. """
        return self.reposted_user({}).get('id', default)

    def reposted_user_name(self, default=None):
        """ Returns the reposted user's name, or a default value. """
        return self.reposted_user({}).get('username', default)

    def is_control(self):
        """ Returns True if the event is an API control message. """
        return self._type == 'control'

    def is_delete(self):
        """ Returns True if the event is a deletion. """
        return self._meta.get('is_deleted', False)

    def is_post_type(self):
        """ Returns True if the meta type is `post`. """
        return self._type == 'post'

    def is_post(self):
        """ Returns True if the event is a new post. """
        return (self.is_post_type() and
                not self.is_delete() and
                not self.is_repost())

    def is_repost(self):
        """ Returns True if the event is a new repost. """
        return (self.is_post_type() and
                not self.is_delete() and
                'repost_of' in self._data)

    def is_post_delete(self):
        """ Returns True if the event is a post deletion. """
        return (self.is_post_type() and
                self.is_delete())

    def is_follow_type(self):
        """ Returns True if the meta type is `user_follow`. """
        return self._type == 'user_follow'

    def is_follow(self):
        """ Returns True if the event is a follow event. """
        return self.is_follow_type() and not self.is_delete()

    def is_unfollow(self):
        """ Returns True if the event is an unfollow event. """
        return self.is_follow_type() and self.is_delete()

    def is_star_type(self):
        """ Returns True if the meta type is `star`. """
        return self._type == 'star'

    def is_star(self):
        """ Returns True if the event is a star event. """
        return self.is_star_type() and not self.is_delete()

    def is_unstar(self):
        """ Returns True if the event is an un-star event. """
        return self.is_star_type() and self.is_delete()
