from io import BytesIO

import requests
from requests.auth import AuthBase
import re
import logging
import base64
from typing import Callable, Any

from requests.cookies import MockRequest, MockResponse
from requests.structures import CaseInsensitiveDict
from requests.utils import get_encoding_from_headers

from .exceptions import *
from .record import GlideRecord
from .attachment import Attachment
from .utils import get_instance, MockHeaders
from .auth import ServiceNowFlow


class ServiceNowClient(object):
    """
    ServiceNow Python Client

    :param str instance: The instance to connect to e.g. ``https://dev00000.service-now.com`` or ``dev000000``
    :param auth: Username password combination ``(name,pass)`` or :class:`pysnc.ServiceNowOAuth2` or ``requests.sessions.Session`` or ``requests.auth.AuthBase`` object
    :param proxy: HTTP(s) proxy to use as a str ``'http://proxy:8080`` or dict ``{'http':'http://proxy:8080'}``
    :param bool verify: Verify the SSL/TLS certificate OR the certificate to use. Useful if you're using a self-signed HTTPS proxy.
    :param cert: if String, path to ssl client cert file (.pem). If Tuple, (‘cert’, ‘key’) pair.
    """
    def __init__(self, instance, auth, proxy=None, verify=None, cert=None):
        self._log = logging.getLogger(__name__)
        self.__instance = get_instance(instance)

        if auth is not None and cert is not None:
            raise AuthenticationException('Cannot specify both auth and cert')
        elif isinstance(auth, (list, tuple)) and len(auth) == 2:
            self.__user = auth[0]
            auth = requests.auth.HTTPBasicAuth(auth[0], auth[1])

            self.__session = requests.session()
            self.__session.auth = auth
        elif isinstance(auth, AuthBase):
            self.__session = requests.session()
            self.__session.auth = auth
        elif isinstance(auth, requests.sessions.Session):
            # maybe we've got an oauth token? Let this be permissive
            self.__session = auth
        elif isinstance(auth, ServiceNowFlow):
            self.__session = auth.authenticate(self.__instance)
        elif cert is not None:
            self.__session.cert = cert
        else:
            raise AuthenticationException('No valid authentication method provided')

        if proxy:
            if type(proxy) != dict:
                proxies = dict(http=proxy, https=proxy)
            else:
                proxies = proxy
            self.__session.proxies = proxies
            if verify is None:
                verify = True  # default to verify with proxy
        if verify is not None:
            self.__session.verify = verify

        self.__session.headers.update(dict(Accept="application/json"))

        self.table_api = TableAPI(self)
        self.attachment_api = AttachmentAPI(self)
        self.batch_api = BatchAPI(self)

    def GlideRecord(self, table, batch_size=100) -> GlideRecord:
        """
        Create a :class:`pysnc.GlideRecord` for a given table against the current client

        :param str table: The table name e.g. ``problem``
        :param int batch_size: Batch size (items returned per HTTP request). Default is ``100``.
        :return: :class:`pysnc.GlideRecord`
        """
        return GlideRecord(self, table, batch_size)

    def Attachment(self, table) -> Attachment:
        """
        Create an Attachment object for the current client

        :return: :class:`pysnc.Attachment`
        """
        return Attachment(self, table)

    @property
    def instance(self) -> str:
        """
        The instance we're associated with.

        :return: Instance URI
        :rtype: str
        """
        return self.__instance

    @property
    def session(self):
        """
        :return: The requests session
        """
        return self.__session

    @staticmethod
    def guess_is_sys_id(value) -> bool:
        """
        Attempt to guess if this is a probable sys_id

        :param str value: the value to check
        :return: If this is probably a sys_id
        :rtype: bool
        """
        return re.match(r'^[A-Za-z0-9]{32}$', value) is not None


class API(object):

    def __init__(self, client):
        self._client = client

    @property
    def session(self):
        return self._client.session

    # noinspection PyMethodMayBeStatic
    def _set_params(self, record=None):
        params = {} if record == None else record._parameters()
        params['sysparm_display_value'] = 'all'
        params['sysparm_exclude_reference_link'] = 'true'  # Scratch it!
        params['sysparm_suppress_pagination_header'] = 'true'  # Required for large queries
        return params

    # noinspection PyMethodMayBeStatic
    def _validate_response(self, response: requests.Response) -> None:
        assert response is not None, f"response argument required"
        code = response.status_code
        if code >= 400:
            try:
                rjson = response.json()
                if code == 404:
                    raise NotFoundException(rjson)
                if code == 403:
                    raise RoleException(rjson)
                if code == 401:
                    raise AuthenticationException(rjson)
                raise RequestException(rjson)
            except requests.exceptions.JSONDecodeError:
                raise RequestException(response.text)

    def _send(self, req, stream=False) -> requests.Response:
        # https://stackoverflow.com/a/55889308/253594
        # if we're oauth, we have to do magic for prepared requests

        if hasattr(self.session, 'token'):
            try:
                req.url, req.headers, req.data = self.session._client.add_token(
                    req.url, http_method=req.method, body=req.data, headers=req.headers
                )
            except Exception as e:
                if e.__class__.__name__ == 'TokenExpiredError':
                    # use refresh token to get new token
                    if self.session.auto_refresh_url:
                        if hasattr(req, 'auth'):
                            req.auth = None
                        self.session.refresh_token(self.session.auto_refresh_url)
                    else:
                        raise e
                else:
                    raise e

        request = self.session.prepare_request(req)
        r = self.session.send(request, stream=stream)
        self._validate_response(r)
        return r


class TableAPI(API):

    def _target(self, table, sys_id=None) -> str:
        target = "{url}/api/now/table/{table}".format(url=self._client.instance, table=table)
        if sys_id:
            target = "{}/{}".format(target, sys_id)
        return target

    def list(self, record: GlideRecord) -> requests.Response:
        params = self._set_params(record)
        target_url = self._target(record.table)

        req = requests.Request('GET', target_url, params=params)
        return self._send(req)

    def get(self, record: GlideRecord, sys_id: str) -> requests.Response:
        params = self._set_params(record)
        # delete extra stuff
        if 'sysparm_offset' in params:
            del params['sysparm_offset']

        target_url = self._target(record.table, sys_id)
        req = requests.Request('GET', target_url, params=params)
        return self._send(req)

    def put(self, record: GlideRecord) -> requests.Response:
        return self.patch(record)

    def patch(self, record: GlideRecord) -> requests.Response:
        body = record.serialize()
        params = self._set_params()
        target_url = self._target(record.table, record.sys_id)
        req = requests.Request('PATCH', target_url, params=params, json=body)
        return self._send(req)

    def post(self, record: GlideRecord) -> requests.Response:
        body = record.serialize()
        params = self._set_params()
        target_url = self._target(record.table)
        req = requests.Request('POST', target_url, params=params, json=body)
        return self._send(req)

    def delete(self, record: GlideRecord) -> requests.Response:
        target_url = self._target(record.table, record.sys_id)
        req = requests.Request('DELETE', target_url)
        return self._send(req)


class AttachmentAPI(API):
    API_VERSION = 'v1'

    def _target(self, sys_id=None):
        target = "{url}/api/now/{version}/attachment".format(url=self._client.instance, version=self.API_VERSION)
        if sys_id:
            target = "{}/{}".format(target, sys_id)
        return target

    def get(self, sys_id=None):
        target_url = self._target(sys_id)
        req = requests.Request('GET', target_url, params={})
        return self._send(req)

    def get_file(self, sys_id, stream=True):
        """
        This may be dangerous, as stream is true and if not fully read could leave open handles
        One should always ``with api.get_file(sys_id) as f:``
        """
        target_url = "{}/file".format(self._target(sys_id))
        req = requests.Request('GET', target_url)
        return self._send(req, stream=stream)

    def list(self, attachment: Attachment):
        params = self._set_params(attachment)
        url = self._target()
        req = requests.Request('GET', url, params=params, headers=dict(Accept="application/json"))
        return self._send(req)

    def upload_file(self, file_name, table_name, table_sys_id, file, content_type=None, encryption_context=None):
        url = f"{self._target()}/file"
        params = {'file_name': file_name, 'table_name': table_name, 'table_sys_id': f"{table_sys_id}"}
        if encryption_context:
            params['encryption_context'] = encryption_context

        if not content_type:
            content_type = 'application/octet-stream'
        headers = {'Content-Type': content_type}

        req = requests.Request('POST', url, params=params, headers=headers, data=file)
        return self._send(req)

    def delete(self, sys_id):
        target_url = self._target(sys_id)
        req = requests.Request('DELETE', target_url)
        return self._send(req)


class BatchAPI(API):
    API_VERSION = 'v1'

    def __init__(self, client):
        API.__init__(self, client)
        self.__requests = []
        self.__stored_requests = {}
        self.__hooks = {}
        self.__request_id = 0

    def _batch_target(self):
        return "{url}/api/now/{version}/batch".format(url=self._client.instance, version=self.API_VERSION)

    def _table_target(self, table, sys_id=None):
        # note: the instance is still in here so requests behaves normally when preparing requests
        target = "{url}/api/now/table/{table}".format(url=self._client.instance, table=table)
        if sys_id:
            target = "{}/{}".format(target, sys_id)
        return target

    def _next_id(self):
        self.__request_id += 1
        return self.__request_id

    def _add_request(self, request: requests.Request, hook: Callable):
        prepared = request.prepare()
        request_id = str(id(prepared))
        headers = [{'name': k, 'value': v} for (k,v) in prepared.headers.items()]
        relative_url = prepared.url[prepared.url.index('/', 8):] ## slice from the first non https:// slash

        now_request = {
            'id': request_id,
            'method': prepared.method,
            'url': relative_url,
            'headers': headers,
            #'exclude_response_headers': False
        }
        if prepared.body:
            now_request['body'] = base64.b64encode(prepared.body).decode()
        self.__hooks[request_id] = hook
        self.__stored_requests[request_id] = prepared
        self.__requests.append(now_request)

    def _transform_response(self, req: requests.PreparedRequest, serviced_request) -> requests.Response:
        #if 'body' in serviced_request:
        #    serviced_request['body'] = base64.b64decode(serviced_request['body'])

        # modeled after requests.adapters.HttpAdapter.build_response
        response = requests.Response()
        response.status_code = serviced_request['status_code']
        headers = {k: v for (k, v) in [(e['name'], e['value']) for e in serviced_request.get("headers", [])]}
        print(serviced_request)
        print(headers)
        response.headers = CaseInsensitiveDict(headers)
        response.encoding = get_encoding_from_headers(response.headers)

        body = base64.b64decode(serviced_request.get('body', ''))
        response.raw = BytesIO(body)

        if isinstance(req.url, bytes):
            response.url = req.url.decode("utf-8")
        else:
            response.url = req.url

        # cookies - kinda hack an adapter in
        req = MockRequest(req)
        res = MockResponse(MockHeaders(headers))
        response.cookies.extract_cookies(res, req)

        response.req = req
        # response.connection = None

        return response

        #return serviced_request

    def execute(self):
        bid = self._next_id()
        body = {
            'batch_request_id': bid,
            'rest_requests': self.__requests
        }
        r = self.session.post(self._batch_target(), json=body)
        self._validate_response(r)
        data = r.json()
        try:
            assert str(bid) == data['batch_request_id'], f"How did we get a response id different from {bid}"

            for response in data['serviced_requests']:
                response_id = response['id']
                assert response_id in self.__hooks, f"Somehow has no hook for {response_id}"
                assert response_id in self.__stored_requests, f"Somehow we did not store request for {response_id}"
                self.__hooks[response['id']](self._transform_response(self.__stored_requests[response_id], response))

            for response_id in data['unserviced_requests']:
                assert response_id in self.__hooks, f"Somehow has no hook for {response_id}"
                self.__hooks[response_id]()  # just call it with no args...
        finally:
            self.__stored_requests = {}
            self.__requests = []
            self.__hooks = {}

    def get(self, record: GlideRecord, sys_id: str, hook: Callable) -> None:
        params = self._set_params(record)
        if 'sysparm_offset' in params:
            del params['sysparm_offset']
        target_url = self._table_target(record.table, sys_id)
        req = requests.Request('GET', target_url, params=params)
        self._add_request(req, hook)

    def put(self, record: GlideRecord, hook: Callable) -> None:
        self.patch(record, hook)

    def patch(self, record: GlideRecord, hook: Callable) -> None:
        body = record.serialize()
        params = self._set_params()
        target_url = self._table_target(record.table, record.sys_id)
        req = requests.Request('PATCH', target_url, params=params, json=body)
        self._add_request(req, hook)

    def post(self, record: GlideRecord, hook: Callable):
        body = record.serialize()
        params = self._set_params()
        target_url = self._table_target(record.table)
        req = requests.Request('POST', target_url, params=params, json=body)
        self._add_request(req, hook)

    def delete(self, record: GlideRecord, hook: Callable):
        target_url = self._table_target(record.table, record.sys_id)
        req = requests.Request('DELETE', target_url)
        self._add_request(req, hook)

    def list(self, record: GlideRecord, hook: Callable):
        params = self._set_params(record)
        target_url = self._table_target(record.table)

        req = requests.Request('GET', target_url, params=params)
        self._add_request(req, hook)
