from contextlib import suppress
import inspect
import logging
import json
import re

from requests import Response
import http.client as http

from awxkit.utils import PseudoNamespace, is_relative_endpoint, are_same_endpoint, super_dir_set, is_list_or_tuple, to_str
from awxkit.api import utils
from awxkit.api.client import Connection
from awxkit.api.registry import URLRegistry
from awxkit.config import config
import awxkit.exceptions as exc


log = logging.getLogger(__name__)


_page_registry = URLRegistry()
get_registered_page = _page_registry.get


def is_license_invalid(response):
    if re.match(r".*Invalid license.*", response.text):
        return True
    if re.match(r".*Missing 'eula_accepted' property.*", response.text):
        return True
    if re.match(r".*'eula_accepted' must be True.*", response.text):
        return True
    if re.match(r".*Invalid license data.*", response.text):
        return True


def is_license_exceeded(response):
    if re.match(r".*license range of.*instances has been exceeded.*", response.text):
        return True
    if re.match(r".*License count of.*instances has been reached.*", response.text):
        return True
    if re.match(r".*License count of.*instances has been exceeded.*", response.text):
        return True
    if re.match(r".*License has expired.*", response.text):
        return True
    if re.match(r".*License is missing.*", response.text):
        return True


def is_duplicate_error(response):
    if re.match(r".*already exists.*", response.text):
        return True


def register_page(urls, page_cls):
    if not _page_registry.default:
        from awxkit.api.pages import Base

        _page_registry.setdefault(Base)

    if not is_list_or_tuple(urls):
        urls = [urls]
    # Register every methodless page with wildcard method
    # until more granular page objects exist (options, head, etc.)
    updated_urls = []
    for url_method_pair in urls:
        if isinstance(url_method_pair, str):
            url = url_method_pair
            method = '.*'
        else:
            url, method = url_method_pair
        updated_urls.append((url, method))

    page_cls.endpoint = updated_urls[0][0]
    return _page_registry.register(updated_urls, page_cls)


def objectify_response_json(response):
    """return a PseudoNamespace() from requests.Response.json()."""
    try:
        json = response.json()
    except ValueError:
        json = dict()

    # PseudoNamespace arg must be a dict, and json can be an array.
    # TODO: Assess if list elements should be PseudoNamespace
    if isinstance(json, dict):
        return PseudoNamespace(json)
    return json


class Page(object):
    endpoint = ''

    def __init__(self, connection=None, *a, **kw):
        if 'endpoint' in kw:
            self.endpoint = kw['endpoint']

        self.connection = connection or Connection(config.base_url, kw.get('verify', not config.assume_untrusted))

        self.r = kw.get('r', None)
        self.json = kw.get('json', objectify_response_json(self.r) if self.r else {})
        self.last_elapsed = kw.get('last_elapsed', None)

    def __getattr__(self, name):
        if 'json' in self.__dict__ and name in self.json:
            value = self.json[name]
            if not isinstance(value, TentativePage) and is_relative_endpoint(value):
                value = TentativePage(value, self.connection)
            elif isinstance(value, dict):
                for key, item in value.items():
                    if not isinstance(item, TentativePage) and is_relative_endpoint(item):
                        value[key] = TentativePage(item, self.connection)
            return value
        raise AttributeError("{!r} object has no attribute {!r}".format(self.__class__.__name__, name))

    def __setattr__(self, name, value):
        if 'json' in self.__dict__ and name in self.json:
            # Update field only.  For new field use explicit patch
            self.patch(**{name: value})
        else:
            self.__dict__[name] = value

    def __str__(self):
        if hasattr(self, 'json'):
            return json.dumps(self.json, indent=4)
        return str(super(Page, self).__repr__())

    __repr__ = __str__

    def __dir__(self):
        attrs = super_dir_set(self.__class__)
        if 'json' in self.__dict__ and hasattr(self.json, 'keys'):
            attrs.update(self.json.keys())
        return sorted(attrs)

    def __getitem__(self, key):
        return getattr(self, key)

    def __iter__(self):
        return iter(self.json)

    @property
    def __item_class__(self):
        """Returns the class representing a single 'Page' item"""
        return self.__class__

    @classmethod
    def from_json(cls, raw, connection=None):
        resp = Response()
        data = json.dumps(raw)
        resp._content = bytes(data, 'utf-8')
        resp.encoding = 'utf-8'
        resp.status_code = 200
        return cls(r=resp, connection=connection)

    @property
    def bytes(self):
        if self.r is None:
            return b''
        return self.r.content

    def extract_data(self, response):
        """Takes a `requests.Response` and returns a data dict."""
        try:
            data = response.json()
        except ValueError as e:  # If there was no json to parse
            data = {}
            if response.text or response.status_code not in (200, 202, 204):
                text = response.text
                if len(text) > 1024:
                    text = text[:1024] + '... <<< Truncated >>> ...'
                log.debug("Unable to parse JSON response ({0.status_code}): {1} - '{2}'".format(response, e, text))

        return data

    def page_identity(self, response, request_json=None):
        """Takes a `requests.Response` and
        returns a new __item_class__ instance if the request method is not a get, or returns
           a __class__ instance if the request path is different than the caller's `endpoint`.
        """
        request_path = response.request.path_url
        if request_path == '/migrations_notran/':
            raise exc.IsMigrating('You have been redirected to the migration-in-progress page.')
        request_method = response.request.method.lower()

        self.last_elapsed = response.elapsed

        if isinstance(request_json, dict) and 'ds' in request_json:
            ds = request_json.ds
        else:
            ds = None

        data = self.extract_data(response)
        exc_str = "%s (%s) received" % (http.responses[response.status_code], response.status_code)

        exception = exception_from_status_code(response.status_code)
        if exception:
            raise exception(exc_str, data)

        if response.status_code in (http.OK, http.CREATED, http.ACCEPTED):
            # Not all JSON responses include a URL.  Grab it from the request
            # object, if needed.
            if 'url' in data:
                endpoint = data['url']
            else:
                endpoint = request_path

            data = objectify_response_json(response)

            if request_method in ('get', 'patch', 'put'):
                #  Update existing resource and return it
                if are_same_endpoint(self.endpoint, request_path):
                    self.json = data
                    self.r = response
                    return self

            registered_type = get_registered_page(request_path, request_method)
            return registered_type(self.connection, endpoint=endpoint, json=data, last_elapsed=response.elapsed, r=response, ds=ds)

        elif response.status_code == http.FORBIDDEN:
            if is_license_invalid(response):
                raise exc.LicenseInvalid(exc_str, data)
            elif is_license_exceeded(response):
                raise exc.LicenseExceeded(exc_str, data)
            else:
                raise exc.Forbidden(exc_str, data)

        elif response.status_code == http.BAD_REQUEST:
            if is_license_invalid(response):
                raise exc.LicenseInvalid(exc_str, data)
            if is_duplicate_error(response):
                raise exc.Duplicate(exc_str, data)
            else:
                raise exc.BadRequest(exc_str, data)
        else:
            raise exc.Unknown(exc_str, data)

    def update_identity(self, obj):
        """Takes a `Page` and updates attributes to reflect its content"""
        self.endpoint = obj.endpoint
        self.json = obj.json
        self.last_elapsed = obj.last_elapsed
        self.r = obj.r
        return self

    def delete(self):
        r = self.connection.delete(self.endpoint)
        with suppress(exc.NoContent):
            return self.page_identity(r)

    def get(self, all_pages=False, **query_parameters):
        r = self.connection.get(self.endpoint, query_parameters)
        page = self.page_identity(r)
        if all_pages and getattr(page, 'next', None):
            paged_results = [r.json()['results']]
            while page.next:
                r = self.connection.get(self.next)
                page = self.page_identity(r)
                paged_results.append(r.json()['results'])
            json = r.json()
            json['results'] = []
            for page in paged_results:
                json['results'].extend(page)
            page = self.__class__.from_json(json, connection=self.connection)
        return page

    def head(self):
        r = self.connection.head(self.endpoint)
        return self.page_identity(r)

    def options(self):
        r = self.connection.options(self.endpoint)
        return self.page_identity(r)

    def patch(self, **json):
        r = self.connection.patch(self.endpoint, json)
        return self.page_identity(r, request_json=json)

    def post(self, json={}):
        r = self.connection.post(self.endpoint, json)
        return self.page_identity(r, request_json=json)

    def put(self, json=None):
        """If a payload is supplied, PUT the payload. If not, submit our existing page JSON as our payload."""
        json = self.json if json is None else json
        r = self.connection.put(self.endpoint, json=json)
        return self.page_identity(r, request_json=json)

    def get_related(self, related_name, **kwargs):
        assert related_name in self.json.get('related', [])
        endpoint = self.json['related'][related_name]
        return self.walk(endpoint, **kwargs)

    def walk(self, endpoint, **kw):
        page_cls = get_registered_page(endpoint)
        return page_cls(self.connection, endpoint=endpoint).get(**kw)

    def get_natural_key(self, cache=None):
        if cache is None:
            cache = PageCache()

        if not getattr(self, 'NATURAL_KEY', None):
            log.warning("This object does not have a natural key: %s", getattr(self, 'endpoint', ''))
            return None

        natural_key = {}
        for key in self.NATURAL_KEY:
            if key in self.related:
                related_endpoint = cache.get_page(self.related[key])
                if related_endpoint is not None:
                    natural_key[key] = related_endpoint.get_natural_key(cache=cache)
                else:
                    natural_key[key] = None
            elif key in self:
                natural_key[key] = self[key]

        natural_key['type'] = self['type']
        return natural_key


_exception_map = {
    http.NO_CONTENT: exc.NoContent,
    http.NOT_FOUND: exc.NotFound,
    http.INTERNAL_SERVER_ERROR: exc.InternalServerError,
    http.BAD_GATEWAY: exc.BadGateway,
    http.METHOD_NOT_ALLOWED: exc.MethodNotAllowed,
    http.UNAUTHORIZED: exc.Unauthorized,
    http.PAYMENT_REQUIRED: exc.PaymentRequired,
    http.CONFLICT: exc.Conflict,
}


def exception_from_status_code(status_code):
    return _exception_map.get(status_code, None)


class PageList(object):
    NATURAL_KEY = None

    @property
    def __item_class__(self):
        """Returns the class representing a single 'Page' item
        With an inheritence of OrgListSubClass -> OrgList -> PageList -> Org -> Base -> Page, the following
        will return the parent class of the current object (e.g. 'Org').

        Obtaining a page type by registered endpoint is highly recommended over using this method.
        """
        mro = inspect.getmro(self.__class__)
        bl_index = mro.index(PageList)
        return mro[bl_index + 1]

    @property
    def results(self):
        items = []
        for item in self.json['results']:
            endpoint = item.get('url')
            if endpoint is None:
                registered_type = self.__item_class__
            else:
                registered_type = get_registered_page(endpoint)
            items.append(registered_type(self.connection, endpoint=endpoint, json=item, r=self.r))
        return items

    def go_to_next(self):
        if self.next:
            next_page = self.__class__(self.connection, endpoint=self.next)
            return next_page.get()

    def go_to_previous(self):
        if self.previous:
            prev_page = self.__class__(self.connection, endpoint=self.previous)
            return prev_page.get()

    def create(self, *a, **kw):
        return self.__item_class__(self.connection).create(*a, **kw)

    def get_natural_key(self, cache=None):
        log.warning("This object does not have a natural key: %s", getattr(self, 'endpoint', ''))
        return None


class TentativePage(str):
    def __new__(cls, endpoint, connection):
        return super(TentativePage, cls).__new__(cls, to_str(endpoint))

    def __init__(self, endpoint, connection):
        self.endpoint = to_str(endpoint)
        self.connection = connection

    def _create(self):
        return get_registered_page(self.endpoint)(self.connection, endpoint=self.endpoint)

    def get(self, **params):
        return self._create().get(**params)

    def create_or_replace(self, **query_parameters):
        """Create an object, and if any other item shares the name, delete that one first.

        Generally, requires 'name' of object.

        Exceptions:
          - Users are looked up by username
          - Teams need to be looked up by name + organization
        """
        page = None
        # look up users by username not name
        if 'users' in self:
            assert query_parameters.get('username'), 'For this resource, you must call this method with a "username" to look up the object by'
            page = self.get(username=query_parameters['username'])
        else:
            assert query_parameters.get('name'), 'For this resource, you must call this method with a "name" to look up the object by'
            if query_parameters.get('organization'):
                if isinstance(query_parameters.get('organization'), int):
                    page = self.get(name=query_parameters['name'], organization=query_parameters.get('organization'))
                else:
                    page = self.get(name=query_parameters['name'], organization=query_parameters.get('organization').id)
            else:
                page = self.get(name=query_parameters['name'])
        if page and page.results:
            for item in page.results:
                # We found a duplicate item, we will delete it
                # Some things, like inventory scripts, allow multiple scripts
                # by same name as long as they have different organization
                item.delete()
        # Now that we know that there is no duplicate, we create a new object
        return self.create(**query_parameters)

    def get_or_create(self, **query_parameters):
        """Get an object by this name or id if it exists, otherwise create it.

        Exceptions:
          - Users are looked up by username
          - Teams need to be looked up by name + organization
        """
        page = None
        # look up users by username not name
        if query_parameters.get('username') and 'users' in self:
            page = self.get(username=query_parameters['username'])
        if query_parameters.get('name'):
            if query_parameters.get('organization'):
                if isinstance(query_parameters.get('organization'), int):
                    page = self.get(name=query_parameters['name'], organization=query_parameters.get('organization'))
                else:
                    page = self.get(name=query_parameters['name'], organization=query_parameters.get('organization').id)
            else:
                page = self.get(name=query_parameters['name'])

        elif query_parameters.get('id'):
            page = self.get(id=query_parameters['id'])
        if page and page.results:
            item = page.results.pop()
            return item.url.get()
        else:
            # We did not find it given these params, we will create it instead
            return self.create(**query_parameters)

    def post(self, payload={}):
        return self._create().post(payload)

    def put(self):
        return self._create().put()

    def patch(self, **payload):
        return self._create().patch(**payload)

    def delete(self):
        return self._create().delete()

    def options(self):
        return self._create().options()

    def create(self, *a, **kw):
        return self._create().create(*a, **kw)

    def payload(self, *a, **kw):
        return self._create().payload(*a, **kw)

    def create_payload(self, *a, **kw):
        return self._create().create_payload(*a, **kw)

    def __str__(self):
        if hasattr(self, 'endpoint'):
            return self.endpoint
        return super(TentativePage, self).__str__()

    __repr__ = __str__

    def __eq__(self, other):
        return self.endpoint == other

    def __ne__(self, other):
        return self.endpoint != other


class PageCache(object):
    def __init__(self):
        self.options = {}
        self.pages_by_url = {}
        self.pages_by_natural_key = {}

    def get_options(self, page):
        url = page.endpoint if isinstance(page, Page) else str(page)
        if url in self.options:
            return self.options[url]

        try:
            options = page.options()
        except exc.Common:
            log.error("This endpoint raised an error: %s", url)
            return self.options.setdefault(url, None)

        warning = options.r.headers.get('Warning', '')
        if '299' in warning and 'deprecated' in warning:
            log.warning("This endpoint is deprecated: %s", url)
            return self.options.setdefault(url, None)

        return self.options.setdefault(url, options)

    def set_page(self, page):
        log.debug("set_page: %s %s", type(page), page.endpoint)
        self.pages_by_url[page.endpoint] = page
        if getattr(page, 'NATURAL_KEY', None):
            log.debug("set_page has natural key fields.")
            natural_key = page.get_natural_key(cache=self)
            if natural_key is not None:
                log.debug("set_page natural_key: %s", repr(natural_key))
                self.pages_by_natural_key[utils.freeze(natural_key)] = page.endpoint
        if 'results' in page:
            for p in page.results:
                self.set_page(p)
        return page

    def get_page(self, page):
        url = page.endpoint if isinstance(page, Page) else str(page)
        if url in self.pages_by_url:
            return self.pages_by_url[url]

        try:
            page = page.get(all_pages=True)
        except exc.Common:
            log.error("This endpoint raised an error: %s", url)
            return self.pages_by_url.setdefault(url, None)

        warning = page.r.headers.get('Warning', '')
        if '299' in warning and 'deprecated' in warning:
            log.warning("This endpoint is deprecated: %s", url)
            return self.pages_by_url.setdefault(url, None)

        log.debug("get_page: %s", page.endpoint)
        return self.set_page(page)

    def get_by_natural_key(self, natural_key):
        endpoint = self.pages_by_natural_key.get(utils.freeze(natural_key))
        log.debug("get_by_natural_key: %s, endpoint: %s", repr(natural_key), endpoint)
        if endpoint:
            return self.get_page(endpoint)
