# Copyright 2014 Facebook, Inc.

# You are hereby granted a non-exclusive, worldwide, royalty-free license to
# use, copy, modify, and distribute this software in source code or binary
# form for use in connection with the web services and APIs provided by
# Facebook.

# As with any software that integrates with the Facebook platform, your use
# of this software is subject to the Facebook Developer Principles and
# Policies [http://developers.facebook.com/policy/]. This copyright notice
# shall be included in all copies or substantial portions of the software.

# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL
# THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
# DEALINGS IN THE SOFTWARE.

from facebook_business.session import FacebookSession
from facebook_business import apiconfig

from facebook_business.exceptions import (
    FacebookRequestError,
    FacebookBadObjectError,
    FacebookUnavailablePropertyException,
    FacebookBadParameterError,
)
from facebook_business.utils import api_utils
from facebook_business.utils import urls

from contextlib import contextmanager
import copy
from six.moves import http_client
import os
import json
import six
import re

try:
  # Since python 3
  from six.moves import collections_abc
except ImportError:
  # Won't work after python 3.8
  import collections as collections_abc

from facebook_business.adobjects.objectparser import ObjectParser
from facebook_business.typechecker import TypeChecker


"""
api module contains classes that make http requests to Facebook's graph API.
"""


class FacebookResponse(object):

    """Encapsulates an http response from Facebook's Graph API."""

    def __init__(self, body=None, http_status=None, headers=None, call=None):
        """Initializes the object's internal data.
        Args:
            body (optional): The response body as text.
            http_status (optional): The http status code.
            headers (optional): The http headers.
            call (optional): The original call that was made.
        """
        self._body = body
        self._http_status = http_status
        self._headers = headers or {}
        self._call = call

    def body(self):
        """Returns the response body."""
        return self._body

    def json(self):
        """Returns the response body -- in json if possible."""
        try:
            return json.loads(self._body)
        except (TypeError, ValueError):
            return self._body

    def headers(self):
        """Return the response headers."""
        return self._headers

    def etag(self):
        """Returns the ETag header value if it exists."""
        return self._headers.get('ETag')

    def status(self):
        """Returns the http status code of the response."""
        return self._http_status

    def is_success(self):
        """Returns boolean indicating if the call was successful."""

        json_body = self.json()

        if isinstance(json_body, collections_abc.Mapping) and 'error' in json_body:
            # Is a dictionary, has error in it
            return False
        elif bool(json_body):
            # Has body and no error
            if 'success' in json_body:
                return json_body['success']
            # API can retuen a success 200 when service unavailable occurs
            return 'Service Unavailable' not in json_body
        elif self._http_status == http_client.NOT_MODIFIED:
            # ETAG Hit
            return True
        elif self._http_status == http_client.OK:
            # HTTP Okay
            return True
        else:
            # Something else
            return False

    def is_failure(self):
        """Returns boolean indicating if the call failed."""
        return not self.is_success()

    def error(self):
        """
        Returns a FacebookRequestError (located in the exceptions module) with
        an appropriate debug message.
        """
        if self.is_failure():
            return FacebookRequestError(
                "Call was not successful",
                self._call,
                self.status(),
                self.headers(),
                self.body(),
            )
        else:
            return None


class FacebookAdsApi(object):

    """Encapsulates session attributes and methods to make API calls.
    Attributes:
        SDK_VERSION (class): indicating sdk version.
        HTTP_METHOD_GET (class): HTTP GET method name.
        HTTP_METHOD_POST (class): HTTP POST method name
        HTTP_METHOD_DELETE (class): HTTP DELETE method name
        HTTP_DEFAULT_HEADERS (class): Default HTTP headers for requests made by
            this sdk.
    """

    SDK_VERSION = apiconfig.ads_api_config['SDK_VERSION']

    API_VERSION = apiconfig.ads_api_config['API_VERSION']

    HTTP_METHOD_GET = 'GET'

    HTTP_METHOD_POST = 'POST'

    HTTP_METHOD_DELETE = 'DELETE'

    HTTP_DEFAULT_HEADERS = {
        'User-Agent': "fbbizsdk-python-%s" % SDK_VERSION,
    }

    _default_api = None
    _default_account_id = None

    def __init__(self, session, api_version=None, enable_debug_logger=False):
        """Initializes the api instance.
        Args:
            session: FacebookSession object that contains a requests interface
                and attribute GRAPH (the Facebook GRAPH API URL).
            api_version: API version
        """
        self._session = session
        self._num_requests_succeeded = 0
        self._num_requests_attempted = 0
        self._api_version = api_version or self.API_VERSION
        self._enable_debug_logger = enable_debug_logger

    def get_num_requests_attempted(self):
        """Returns the number of calls attempted."""
        return self._num_requests_attempted

    def get_num_requests_succeeded(self):
        """Returns the number of calls that succeeded."""
        return self._num_requests_succeeded

    @classmethod
    def init(
        cls,
        app_id=None,
        app_secret=None,
        access_token=None,
        account_id=None,
        api_version=None,
        proxies=None,
        timeout=None,
        debug=False,
        crash_log=True,
    ):
        session = FacebookSession(app_id, app_secret, access_token, proxies,
                                  timeout)
        api = cls(session, api_version, enable_debug_logger=debug)
        cls.set_default_api(api)

        if account_id:
            cls.set_default_account_id(account_id)

        if crash_log:
            from facebook_business.crashreporter import CrashReporter
            if debug:
                CrashReporter.enableLogging()
            CrashReporter.enable()

        return api

    @classmethod
    def set_default_api(cls, api_instance):
        """Sets the default api instance.
        When making calls to the api, objects will revert to using the default
        api if one is not specified when initializing the objects.
        Args:
            api_instance: The instance which to set as default.
        """
        cls._default_api = api_instance

    @classmethod
    def get_default_api(cls):
        """Returns the default api instance."""
        return cls._default_api

    @classmethod
    def set_default_account_id(cls, account_id):
        account_id = str(account_id)
        if account_id.find('act_') == -1:
            raise ValueError(
                "Account ID provided in FacebookAdsApi.set_default_account_id "
                "expects a string that begins with 'act_'",
            )
        cls._default_account_id = account_id

    @classmethod
    def get_default_account_id(cls):
        return cls._default_account_id

    def call(
        self,
        method,
        path,
        params=None,
        headers=None,
        files=None,
        url_override=None,
        api_version=None,
    ):
        """Makes an API call.
        Args:
            method: The HTTP method name (e.g. 'GET').
            path: A tuple of path tokens or a full URL string. A tuple will
                be translated to a url as follows:
                graph_url/tuple[0]/tuple[1]...
                It will be assumed that if the path is not a string, it will be
                iterable.
            params (optional): A mapping of request parameters where a key
                is the parameter name and its value is a string or an object
                which can be JSON-encoded.
            headers (optional): A mapping of request headers where a key is the
                header name and its value is the header value.
            files (optional): An optional mapping of file names to binary open
                file objects. These files will be attached to the request.
        Returns:
            A FacebookResponse object containing the response body, headers,
            http status, and summary of the call that was made.
        Raises:
            FacebookResponse.error() if the request failed.
        """
        if not params:
            params = {}
        if not headers:
            headers = {}
        if not files:
            files = {}

        api_version = api_version or self._api_version

        if api_version and not re.search('v[0-9]+\.[0-9]+', api_version):
            raise FacebookBadObjectError(
                'Please provide the API version in the following format: %s'
                % self.API_VERSION,
            )

        self._num_requests_attempted += 1

        if not isinstance(path, six.string_types):
            # Path is not a full path
            path = "/".join((
                url_override or self._session.GRAPH,
                api_version,
                '/'.join(map(str, path)),
            ))

        # Include api headers in http request
        headers = headers.copy()
        headers.update(FacebookAdsApi.HTTP_DEFAULT_HEADERS)

        if params:
            params = _top_level_param_json_encode(params)

        # Get request response and encapsulate it in a FacebookResponse
        if method in ('GET', 'DELETE'):
            response = self._session.requests.request(
                method,
                path,
                params=params,
                headers=headers,
                files=files,
                timeout=self._session.timeout
            )

        else:
            response = self._session.requests.request(
                method,
                path,
                data=params,
                headers=headers,
                files=files,
                timeout=self._session.timeout
            )
        if self._enable_debug_logger:
            import curlify
            print(curlify.to_curl(response.request))
        fb_response = FacebookResponse(
            body=response.text,
            headers=response.headers,
            http_status=response.status_code,
            call={
                'method': method,
                'path': path,
                'params': params,
                'headers': headers,
                'files': files,
            },
        )

        if fb_response.is_failure():
            raise fb_response.error()

        self._num_requests_succeeded += 1
        return fb_response

    def new_batch(self):
        """
        Returns a new FacebookAdsApiBatch, which when executed will go through
        this api.
        """
        return FacebookAdsApiBatch(api=self)


class FacebookAdsApiBatch(object):

    """
    Exposes methods to build a sequence of calls which can be executed with
    a single http request.
    Note: Individual exceptions won't be thrown for each call that fails.
        The success and failure callback functions corresponding to a call
        should handle its success or failure.
    """

    def __init__(self, api, success=None, failure=None):
        self._api = api
        self._files = []
        self._batch = []
        self._success_callbacks = []
        self._failure_callbacks = []
        if success is not None:
            self._success_callbacks.append(success)
        if failure is not None:
            self._failure_callbacks.append(failure)
        self._requests = []

    def __len__(self):
        return len(self._batch)

    def add(
        self,
        method,
        relative_path,
        params=None,
        headers=None,
        files=None,
        success=None,
        failure=None,
        request=None,
    ):
        """Adds a call to the batch.
        Args:
            method: The HTTP method name (e.g. 'GET').
            relative_path: A tuple of path tokens or a relative URL string.
                A tuple will be translated to a url as follows:
                    <graph url>/<tuple[0]>/<tuple[1]>...
                It will be assumed that if the path is not a string, it will be
                iterable.
            params (optional): A mapping of request parameters where a key
                is the parameter name and its value is a string or an object
                which can be JSON-encoded.
            headers (optional): A mapping of request headers where a key is the
                header name and its value is the header value.
            files (optional): An optional mapping of file names to binary open
                file objects. These files will be attached to the request.
            success (optional): A callback function which will be called with
                the FacebookResponse of this call if the call succeeded.
            failure (optional): A callback function which will be called with
                the FacebookResponse of this call if the call failed.
            request (optional): The APIRequest object
        Returns:
            A dictionary describing the call.
        """
        if not isinstance(relative_path, six.string_types):
            relative_url = '/'.join(relative_path)
        else:
            relative_url = relative_path

        call = {
            'method': method,
            'relative_url': relative_url,
        }

        if params:
            params = _top_level_param_json_encode(params)
            keyvals = ['%s=%s' % (key, urls.quote_with_encoding(value))
                       for key, value in params.items()]
            if method == 'GET':
                call['relative_url'] += '?' + '&'.join(keyvals)
            else:
                call['body'] = '&'.join(keyvals)

        if files:
            call['attached_files'] = ','.join(files.keys())

        if headers:
            call['headers'] = []
            for header in headers:
                batch_formatted_header = {}
                batch_formatted_header['name'] = header
                batch_formatted_header['value'] = headers[header]
                call['headers'].append(batch_formatted_header)

        self._batch.append(call)
        self._files.append(files)
        self._success_callbacks.append(success)
        self._failure_callbacks.append(failure)
        self._requests.append(request)

        return call

    def add_request(
        self,
        request,
        success=None,
        failure=None,
    ):
        """Interface to add a APIRequest to the batch.
        Args:
            request: The APIRequest object to add
            success (optional): A callback function which will be called with
                the FacebookResponse of this call if the call succeeded.
            failure (optional): A callback function which will be called with
                the FacebookResponse of this call if the call failed.
            Returns:
                A dictionary describing the call.
        """
        updated_params = copy.deepcopy(request._params)
        if request._fields:
            updated_params['fields'] = ','.join(request._fields)
        return self.add(
            method=request._method,
            relative_path=request._path,
            params=updated_params,
            files=request._file_params,
            success=success,
            failure=failure,
            request=request,
        )

    def execute(self):
        """Makes a batch call to the api associated with this object.
        For each individual call response, calls the success or failure callback
        function if they were specified.
        Note: Does not explicitly raise exceptions. Individual exceptions won't
        be thrown for each call that fails. The success and failure callback
        functions corresponding to a call should handle its success or failure.
        Returns:
            If some of the calls have failed, returns  a new FacebookAdsApiBatch
            object with those calls. Otherwise, returns None.
        """
        if not self._batch:
            return None
        method = 'POST'
        path = tuple()
        params = {'batch': self._batch}
        files = {}
        for call_files in self._files:
            if call_files:
                files.update(call_files)

        fb_response = self._api.call(
            method,
            path,
            params=params,
            files=files,
        )

        responses = fb_response.json()
        retry_indices = []

        for index, response in enumerate(responses):
            if response:
                body = response.get('body')
                code = response.get('code')
                headers = response.get('headers')

                inner_fb_response = FacebookResponse(
                    body=body,
                    headers=headers,
                    http_status=code,
                    call=self._batch[index],
                )

                if inner_fb_response.is_success():
                    if self._success_callbacks[index]:
                        self._success_callbacks[index](inner_fb_response)
                elif self._failure_callbacks[index]:
                    self._failure_callbacks[index](inner_fb_response)
            else:
                retry_indices.append(index)

        if retry_indices:
            new_batch = self.__class__(self._api)
            new_batch._files = [self._files[index] for index in retry_indices]
            new_batch._batch = [self._batch[index] for index in retry_indices]
            new_batch._success_callbacks = [self._success_callbacks[index]
                                            for index in retry_indices]
            new_batch._failure_callbacks = [self._failure_callbacks[index]
                                            for index in retry_indices]
            return new_batch
        else:
            return None


class FacebookRequest:
    """
    Represents an API request
    """

    def __init__(
        self,
        node_id,
        method,
        endpoint,
        api=None,
        param_checker=TypeChecker({}, {}),
        target_class=None,
        api_type=None,
        allow_file_upload=False,
        response_parser=None,
        include_summary=True,
        api_version=None,
    ):
        """
        Args:
            node_id: The node id to perform the api call.
            method: The HTTP method of the call.
            endpoint: The edge of the api call.
            api (optional): The FacebookAdsApi object.
            param_checker (optional): Parameter checker.
            target_class (optional): The return class of the api call.
            api_type (optional): NODE or EDGE type of the call.
            allow_file_upload (optional): Whether the call allows upload.
            response_parser (optional): An ObjectParser to parse response.
            include_summary (optional): Include "summary".
            api_version (optional): API version.
        """
        self._api = api or FacebookAdsApi.get_default_api()
        self._node_id = node_id
        self._method = method
        self._endpoint = endpoint.replace('/', '')
        self._path = (node_id, endpoint.replace('/', ''))
        self._param_checker = param_checker
        self._target_class = target_class
        self._api_type = api_type
        self._allow_file_upload = allow_file_upload
        self._response_parser = response_parser
        self._include_summary = include_summary
        self._api_version = api_version
        self._params = {}
        self._fields = []
        self._file_params = {}
        self._file_counter = 0
        self._accepted_fields = []
        if target_class is not None:
            self._accepted_fields = target_class.Field.__dict__.values()

    def add_file(self, file_path):
        if not self._allow_file_upload:
            api_utils.warning('Endpoint ' + self._endpoint + ' cannot upload files')
        file_key = 'source' + str(self._file_counter)
        if os.path.isfile(file_path):
            self._file_params[file_key] = file_path
            self._file_counter += 1
        else:
            raise FacebookBadParameterError(
                'Cannot find file ' + file_path + '!',
            )
        return self

    def add_files(self, files):
        if files is None:
            return self
        for file_path in files:
            self.add_file(file_path)
        return self

    def add_field(self, field):
        if field not in self._fields:
            self._fields.append(field)
        if field not in self._accepted_fields:
            api_utils.warning(self._endpoint + ' does not allow field ' + field)
        return self

    def add_fields(self, fields):
        if fields is None:
            return self
        for field in fields:
            self.add_field(field)
        return self

    def add_param(self, key, value):
        if not self._param_checker.is_valid_pair(key, value):
            api_utils.warning('value of ' + key + ' might not be compatible. ' +
                ' Expect ' + self._param_checker.get_type(key) + '; ' +
                ' got ' + str(type(value)))
        if self._param_checker.is_file_param(key):
            self._file_params[key] = value
        else:
            self._params[key] = self._extract_value(value)
        return self

    def add_params(self, params):
        if params is None:
            return self
        for key in params.keys():
            self.add_param(key, params[key])
        return self

    def get_fields(self):
        return list(self._fields)

    def get_params(self):
        return copy.deepcopy(self._params)

    def execute(self):
        params = copy.deepcopy(self._params)
        if self._api_type == "EDGE" and self._method == "GET":
            cursor = Cursor(
                target_objects_class=self._target_class,
                params=params,
                fields=self._fields,
                include_summary=self._include_summary,
                api=self._api,
                node_id=self._node_id,
                endpoint=self._endpoint,
            )
            cursor.load_next_page()
            return cursor
        if self._fields:
            params['fields'] = ','.join(self._fields)
        with open_files(self._file_params) as files:
            response = self._api.call(
                method=self._method,
                path=(self._path),
                params=params,
                files=files,
                api_version=self._api_version,
            )
            if response.error():
                raise response.error()
            if self._response_parser:
                return self._response_parser.parse_single(response.json())
            else:
                return response

    def add_to_batch(self, batch, success=None, failure=None):
        batch.add_request(self, success, failure)

    def _extract_value(self, value):
        if hasattr(value, 'export_all_data'):
            return value.export_all_data()
        elif isinstance(value, list):
            return [self._extract_value(item) for item in value]
        elif isinstance(value, dict):
            return dict((self._extract_value(k), self._extract_value(v))
                for (k, v) in value.items())
        else:
            return value


class Cursor(object):

    """Cursor is an cursor over an object's connections.
        Previously called EdgeIterator.
    Examples:
        >>> me = AdAccountUser('me')
        >>> my_accounts = [act for act in Cursor(me, AdAccount)]
        >>> my_accounts
        [<AdAccount act_abc>, <AdAccount act_xyz>]
    """

    def __init__(
        self,
        source_object=None,
        target_objects_class=None,
        fields=None,
        params=None,
        include_summary=True,
        api=None,
        node_id=None,
        endpoint=None,
        object_parser=None
    ):
        """
        Initializes an cursor over the objects to which there is an edge from
        source_object.
        To initialize, you'll need to provide either (source_object and
        target_objects_class) or (api, node_id, endpoint, and object_parser)
        Args:
            source_object: An AbstractObject instance from which to inspect an
                edge. This object should have an id.
            target_objects_class: Objects traverersed over will be initialized
                with this AbstractObject class.
            fields (optional): A list of fields of target_objects_class to
                automatically read in.
            params (optional): A mapping of request parameters where a key
                is the parameter name and its value is a string or an object
                which can be JSON-encoded.
            include_summary (optional): Include summary.
            api (optional): FacebookAdsApi object.
            node_id (optional): The ID of calling node.
            endpoint (optional): The edge name.
            object_parser (optional): The ObjectParser to parse response.
        """
        self.params = dict(params or {})
        target_objects_class._assign_fields_to_params(fields, self.params)
        self._source_object = source_object
        self._target_objects_class = target_objects_class
        self._node_id = node_id or source_object.get_id_assured()
        self._endpoint = endpoint or target_objects_class.get_endpoint()
        self._api = api or source_object.get_api()
        self._path = (
            self._node_id,
            self._endpoint,
        )
        self._queue = []
        self._headers = []
        self._finished_iteration = False
        self._total_count = None
        self._summary = None
        self._include_summary = include_summary or 'default_summary' in self.params
        self._object_parser = object_parser or ObjectParser(
            api=self._api,
            target_class=self._target_objects_class,
        )

    def __repr__(self):
        return str(self._queue)

    def __len__(self):
        return len(self._queue)

    def __iter__(self):
        return self

    def __next__(self):
        # Load next page at end.
        # If load_next_page returns False, raise StopIteration exception
        if not self._queue and not self.load_next_page():
            raise StopIteration()

        return self._queue.pop(0)

    # Python 2 compatibility.
    next = __next__

    def __getitem__(self, index):
        return self._queue[index]

    def headers(self):
        return self._headers

    def total(self):
        if self._total_count is None:
            raise FacebookUnavailablePropertyException(
                "Couldn't retrieve the object total count for that type "
                "of request.",
            )
        return self._total_count

    def summary(self):
        if self._summary is None or not isinstance(self._summary, dict):
            raise FacebookUnavailablePropertyException(
                "Couldn't retrieve the object summary for that type "
                "of request.",
            )
        return "<Summary> %s" % (
            json.dumps(
                self._summary,
                sort_keys=True,
                indent=4,
                separators=(',', ': '),
            ),
        )

    def load_next_page(self):
        """Queries server for more nodes and loads them into the internal queue.
        Returns:
            True if successful, else False.
        """
        if self._finished_iteration:
            return False

        if (
            self._include_summary and
            'default_summary' not in self.params and
            'summary' not in self.params
        ):
            self.params['summary'] = True

        response_obj = self._api.call(
            'GET',
            self._path,
            params=self.params,
        )
        response = response_obj.json()
        self._headers = response_obj.headers()

        if 'paging' in response and 'next' in response['paging']:
            self._path = response['paging']['next']
            self.params = {}
        else:
            # Indicate if this was the last page
            self._finished_iteration = True

        if (
            self._include_summary and
            'summary' in response and
            'total_count' in response['summary']
        ):
            self._total_count = response['summary']['total_count']

        if self._include_summary and 'summary' in response:
            self._summary = response['summary']

        self._queue = self.build_objects_from_response(response)
        return len(self._queue) > 0

    def get_one(self):
        for obj in self:
            return obj
        return None

    def build_objects_from_response(self, response):
        return self._object_parser.parse_multiple(response)


@contextmanager
def open_files(files):
    opened_files = {}
    for key, path in files.items():
        opened_files.update({key: open(path, 'rb')})
    yield opened_files
    for file in opened_files.values():
        file.close()


def _top_level_param_json_encode(params):
    params = params.copy()

    for param, value in params.items():
        if (
            isinstance(value, (collections_abc.Mapping, collections_abc.Sequence, bool))
            and not isinstance(value, six.string_types)
        ):
            params[param] = json.dumps(
                value,
                sort_keys=True,
                separators=(',', ':'),
            )
        else:
            params[param] = value

    return params
