from __future__ import absolute_import, unicode_literals

from copy import deepcopy

import requests

from .collections import Collection
from .utils import (has_data, has_links, is_collection, is_dict, is_fetched,
                    is_list, is_null, is_related, is_related_list, is_resource,
                    is_resource_identifier)


class Resource(object):
    """ Subclass like this:

            >>> class Foo(jsonapi.Resource):
            ...     TYPE = "foos"
            ...     EDITABLE = ['name', 'age', 'parent']

        EDITABLE values can either be names of attributes or relationships.
    """

    TYPE = None
    EDITABLE = None

    # Creation
    def __init__(self, data=None, **kwargs):
        """ Initialize an API resource instance when you know the type. """

        if 'type' in kwargs and kwargs['type'] != self.TYPE:
            raise ValueError("Invalid type")

        if data is not None:
            # Maybe a HTTP response body was passed:
            # - Parent(requests.get('http://api.com/parents/1').json())
            # - Parent(requests.get('http://api.com/parents/1').json()['data'])
            # Or a relationship:
            # - `Parent(user.relationships['parent'])`
            # - `Parent({'data': {'type': "parents", 'id': "1"}})`
            if 'data' in data:
                included = data.get('included')
                data = data['data']
                if included is not None and 'included' not in data:
                    data['included'] = included
            self._overwrite(**data)
        else:
            self._overwrite(**kwargs)

    def _overwrite(self,
                   # Copied from response to the instance
                   id=None, attributes=None, relationships=None, links=None,
                   # Used to overwrite 'related'
                   included=None,
                   # Used in case of redirect responses
                   redirect=None,
                   # Ignored
                   type=None,
                   # Magic
                   **kwargs):
        """ Write to the basic attributes of Resource. Used by '__init__',
            'reload', '__copy__' and 'save'
        """

        # Handle "magic" kwargs
        if attributes is None:
            attributes = {}
        if relationships is None:
            relationships = {}
        for key, value in kwargs.items():
            if is_related(value) or is_related_list(value):
                relationships[key] = value
            else:
                attributes[key] = value

        # Copy from response
        self.id = id

        self.attributes = deepcopy(attributes)

        if links is not None:
            self.links = deepcopy(links)
        else:
            self.links = {}

        self.redirect = redirect

        # Relationships
        self.relationships, self.related = {}, {}
        for key, value in relationships.items():
            self._set_relationship(key, value)
            relationship = self.relationships[key]
            if is_null(relationship) or has_data(relationship):
                self.set_related(key, value)

        if included is not None:
            included = {(item['type'], item['id']): item for item in included}
            for relationship_name, relationship in self.relationships.items():
                if is_null(relationship) or not has_data(relationship):
                    continue
                if is_list(relationship['data']):  # Plural with data
                    new_items = [
                        included.get((r['type'], r['id']),
                                     self.related[relationship_name][i])
                        for i, r in enumerate(relationship['data'])
                    ]
                    self.set_related(relationship_name, new_items)
                else:  # Singular
                    key = (relationship['data']['type'],
                           relationship['data']['id'])
                    if key in included:
                        self.set_related(relationship_name, included[key])

    def _set_relationship(self, key, value):
        """ Set 'value' as 'key' relationship. For value we accept:

            - A Resource object
            - A relationship (a dict with either 'data', 'links' or both)
            - A resource identifier (a dict with 'type' and 'id')
            - A list of a combination of the above
            - A dict with a 'data' field which is a list of a combination of
              the above
            - None

            Regardless, in the end `self.relationships` will resemble an API
            response's relationships.
        """

        if is_related_list(value):
            if has_data(value):
                data = value['data']
            else:
                data = value
            self.relationships[key] = {'data': [
                self.API.as_resource(item).as_resource_identifier()
                for item in data
            ]}
            if has_links(value):
                self.relationships[key]['links'] = value['links']
        elif is_resource(value):
            self.relationships[key] = value.as_relationship()
        else:
            value = deepcopy(value)
            if not is_null(value) and is_resource_identifier(value):
                value = {'data': value}
            if is_null(value) or has_data(value) or has_links(value):
                self.relationships[key] = value
            else:
                raise ValueError("Invalid type '{}' for relationship '{}'".
                                 format(value, key))

    def set_related(self, key, value):
        """ Set 'value' as 'key' relationship's value. Works only with singular
            relationships. For value we accept:

            - A Resource object
            - A JSON representation of a Resource object
            - A full API response of a Resource object
            - A relationship (a dict with a 'data' field)
            - A resource identifier (a dict with 'type' and 'id')
            - A list of a combination of the above
            - A dict with a 'data' field with a list of a combination of the
              above as value
            - None

            Regardless, in the end `self.related[key]` will be a Resource
            instance or None.
        """

        if key not in self.relationships:
            raise ValueError("Cannot change relationship '{}' because it's "
                             "not an existing relationship.".format(key))

        relationship = self.relationships[key]

        if is_list(value) or (has_data(value) and is_list(value['data'])):
            # Plural
            if has_data(value):
                value = value['data']
            self.related[key] = Collection.from_data(self.API, {'data': []})
            for item in value:
                self.related[key].append(self.API.as_resource(item))
            new_relationship = [item.as_resource_identifier()
                                for item in self.related[key]]
            if new_relationship != relationship['data']:
                relationship['data'] = new_relationship
        else:
            # Singular
            value = self.API.as_resource(value)
            null_to_not_null = is_null(relationship) and not is_null(value)
            not_null_to_null = not is_null(relationship) and is_null(value)
            data_changed = (not is_null(relationship) and
                            not is_null(value) and
                            (relationship['data'] !=
                             value.as_resource_identifier()))
            if null_to_not_null or not_null_to_null or data_changed:
                if value is None:
                    self.relationships[key] = None
                else:
                    self.relationships[key] = value.as_relationship()
            self.related[key] = value

    @classmethod
    def as_resource(cls, data):
        """ Little convenience function when we don't know if we are dealing
            with a Resource instance or a dict describing a relationship.
        """

        try:
            return cls(data)
        except Exception:
            return data

    def to_dict(self):
        if self.redirect:
            return self.redirect

        result = {'type': self.TYPE}
        if self.id:
            result['id'] = self.id
        if self.attributes:
            result['attributes'] = self.attributes
        if self.relationships:
            result['relationships'] = self.relationships
        if self.links:
            result['links'] = self.links
        return result

    # Shortcuts
    def __getattr__(self, attr):
        if attr in ('a', 'attributes', 'R', 'relationships', 'r', 'related',
                    'id', 'links', 'redirect', 'API'):
            return super(Resource, self).__getattribute__(attr)
        elif attr in self.attributes:
            return self.attributes[attr]
        elif attr in self.related:
            return self.related[attr]
        else:
            return super(Resource, self).__getattribute__(attr)

    def __setattr__(self, attr, value):
        if attr in ('id', 'attributes', 'relationships', 'related', 'links',
                    'redirect', 'API'):
            super(Resource, self).__setattr__(attr, value)
        elif attr in self.attributes:
            self.attributes[attr] = value
        elif attr in self.relationships:
            try:
                self.set_related(attr, value)
            except ValueError as e:
                raise AttributeError(str(e))
        else:
            super(Resource, self).__setattr__(attr, value)

    # Fetching
    def reload(self, include=None):
        """ Fetch fresh data from the server for the object. """

        params = None
        if include is not None:
            params = {'include': ','.join(include)}
        url = self.links.get('self', self.get_item_url())
        response_body = self.API.request('get', url, params=params)
        if (isinstance(response_body, requests.Response) and
                response_body.status_code == 303):
            self._overwrite(redirect=response_body.headers['Location'])
        else:
            self._overwrite(included=response_body.get('included'),
                            **response_body['data'])

    @classmethod
    def get(cls, id=None, include=None, **filters):
        """ Get a resource object by its ID. """

        if id is not None:
            instance = cls(id=id)
            instance.reload(include=include)
            return instance
        else:
            result = cls.list()
            if include is not None:
                result = result.include(*include)
            return result.get(**filters)

    def fetch(self, *relationship_names, **kwargs):
        """ Fetches 'relationship', if it wasn't included when fetching 'self';
            `force=True` supported. Usage:

                >>> foo.fetch('parent')

            Related object will be available after that:

                >>> print(foo.related['parent'].attributes['name'])

            Supports plural relationships, but only one page will be available:

                >>> foo.fetch('children')
                >>> # Only first page
                >>> print([child.attributes['name']
                ...        for child in foo.related['children']])
                >>> # All pages
                >>> print([child.attributes['name']
                ...        for child in foo.related['children'].all()])

            If only one positional argument is supplied, it will return the
            related object or collection:

                >>> print(child.fetch('parent').name)

                Equivalent to:

                >>> child.fetch('parent')
                >>> print(child.parent.name)
        """

        force = kwargs.pop('force', False)

        for relationship_name in relationship_names:
            if relationship_name not in self.relationships:
                raise ValueError("{} doesn't have relationship '{}'".
                                 format(repr(self), relationship_name))

        for relationship_name in relationship_names:
            relationship = self.relationships[relationship_name]

            if is_null(relationship):
                continue

            related = self.related.get(relationship_name)
            is_singular_fetched = is_fetched(related)
            is_plural_fetched = (is_collection(related) and
                                 all((is_fetched(item) for item in related)))
            if (is_singular_fetched or is_plural_fetched) and not force:
                # Has been fetched already
                continue

            if has_data(relationship) and not is_list(relationship['data']):
                # Singular relationship
                self.related[relationship_name].reload()
            else:
                # Plural relationship
                url = relationship.\
                    get('links', {}).\
                    get('related', "/{}/{}/{}".format(self.TYPE, self.id,
                                                      relationship_name))
                self.related[relationship_name] = Collection(self.API, url)

        if len(relationship_names) == 1:
            # This way you can do `project.fetch('languages').filter(...)`
            return self.related[relationship_names[0]]

    @classmethod
    def list(cls):
        return Collection(cls.API, "/{}".format(cls.TYPE))

    def _collection_method(method):
        def _method(cls, *args, **kwargs):
            return getattr(cls.list(), method)(*args, **kwargs)
        return classmethod(_method)

    filter = _collection_method('filter')
    page = _collection_method('page')
    include = _collection_method('include')
    sort = _collection_method('sort')
    fields = _collection_method('fields')
    extra = _collection_method('extra')
    all_pages = _collection_method('all_pages')
    all = _collection_method('all')

    # Editing
    def save(self, *fields, **kwargs):
        """ For new instances (that have `.id == None`), everything will be
            saved and 'id' and other server-generated fields will be set.

            For existing instances, if `fields` or `cls.EDITABLE` is set, then
            only these fields will be saved.

            Usage:
                >>> class Foo(Resource):
                ...     type = 'foos'
                ...     EDITABLE = ['name']

                >>> foo = Foo.get(1)
                >>> foo.attributes['name'] = 'footastic'
                >>> foo = foo.save()
                >>> # or
                >>> foo = foo.save('name', ...)
        """

        fields = set(fields)

        for key, value in kwargs.items():
            setattr(self, key, value)
            fields.add(key)

        if self.id is not None:
            self._save_existing(*fields)
        else:
            self._save_new(*fields)

    def _save_existing(self, *fields):
        payload = self.as_resource_identifier()
        payload.update(self._generate_data_for_saving(*fields))
        response_body = self.API.request('patch',
                                         self.get_item_url(),
                                         json={'data': payload})
        self._post_save(response_body)

    def _save_new(self, *fields):
        payload = {'type': self.TYPE}
        if self.id is not None:
            payload['id'] = self.id
        payload.update(self._generate_data_for_saving(*fields))
        response_body = self.API.request('post',
                                         self.get_collection_url(),
                                         json={'data': payload})
        self._post_save(response_body)

    def _generate_data_for_saving(self, *fields):
        result = {}
        editable_fields = fields or self.EDITABLE
        if editable_fields is not None:
            for field in editable_fields:
                if field in self.attributes:
                    result.setdefault('attributes', {})[field] =\
                        self.attributes[field]
                elif field in self.relationships:
                    result.setdefault('relationships', {})[field] =\
                        self.relationships[field]
                else:
                    raise ValueError("Unknown field '{}'".format(field))
        else:
            if self.attributes:
                result['attributes'] = self.attributes
            if self.relationships:
                result['relationships'] = self.relationships
        return result

    def _post_save(self, response_body):
        if (isinstance(response_body, requests.Response) and
                response_body.status_code in (202, 204)):
            # Success, but the server did not return any new data so our object
            # is considered sufficiently populated with data
            return

        data = response_body['data']

        related = dict(self.related)
        for relationship_name, related_instance in list(related.items()):
            if is_collection(related_instance):
                continue  # Plural relationship

            try:
                current_id = related_instance.id
            except Exception:
                current_id = None
            try:
                new_id = data['relationships'][relationship_name]['data']['id']
            except Exception:
                new_id = None
            if current_id != new_id:
                if new_id is not None:
                    # Relationship changed, reset
                    related[relationship_name] = self.API.new(
                        data['relationships'][relationship_name]
                    )
                else:
                    # Relationship removed
                    del related[relationship_name]

        relationships = data.pop('relationships', {})
        relationships.update(related)

        self._overwrite(relationships=relationships,
                        included=response_body.get('included'),
                        **data)

    @classmethod
    def create(cls, *args, **kwargs):
        instance = cls(*args, **kwargs)
        instance._save_new()
        return instance

    # Handling files
    @classmethod
    def create_with_form(cls, type=None, **kwargs):
        """ Simply fowrard kwargs to requests, for non
            'application/vnd.api+json' requests (eg file uploads)
        """

        if type is None and cls.TYPE is not None:
            type = cls.TYPE
        response_body = cls.API.request('post',
                                        cls.get_collection_url(),
                                        **kwargs)
        return cls.API.new(response_body)

    def follow(self):
        if self.redirect is None:
            raise ValueError("Cannot follow a non-redirect response")
        response_body = self.API.request('get', self.redirect)
        if is_list(response_body['data']):
            return Collection.from_data(self.API, response_body)
        elif is_dict(response_body['data']):
            return self.API.new(response_body)
        else:  # Unreachable code
            raise ValueError("Unknown format while following redirect")

    def delete(self):
        """ Deletes a resource from the API. Usage:

                >>> foo.delete()
        """

        self.API.request('delete', self.get_item_url())
        self.id = None

    # Editing relationshps
    def change(self, field, value):
        """ Change a singular relationship. Usage:

                >>> # Change `child`'s parent from `parent_a` to `parent_b`
                >>> parent_a, parent_b = Parent.list()[:2]
                >>> child = Child.get(XXX)
                >>> assert child.relationships['parent'] == parent_a
                >>> child.change('parent', parent_b)

            Also works with resource identifiers in case we don't have the full
            Resource instance:

                >>> # Make sure `child_a` and `child_b` have the same parent,
                >>> # without fetching the parent
                >>> child_a, child_b = Child.list()[:2]
                >>> child_b.change('parent',
                ...                child_a.relationships['parent']['data'])

            Note: Depending on the API implementation, this can probably be
            also achieved by changing the relationship and saving:

                >>> # Change `child`'s parent from `parent_a` to `parent_b`
                >>> parent_a, parent_b = Parent.list()[:2]
                >>> child = Child.get(XXX)
                >>> assert (child.relationships['parent']['data']['id'] ==
                ...         parent_a.id)
                >>> child.relationships['parent'] = {
                ...     'data': parent_b.as_resource_identifier(),
                ... }
                >>> child.save('parent')
        """

        value = self.API.as_resource(value)
        self._edit_relationship('patch', field, value.as_resource_identifier())
        self.relationships[field]['data'] = value.as_resource_identifier()
        if self.related[field] != value:
            self.related[field] = value

    def add(self, field, values):
        """ Adds items to a plural relationship. Usage:

                >>> # Lets add 3 new children to `parent`
                >>> parent = Parent.get(XXX)
                >>> child_a, child_b, child_c = Child.list(
                ...     filters={'parent[ne]': parent.id},
                ... )[:3]
                >>> parent.add('children', [child_a, child_b, child_c])

            Also works with resource identifiers in case we don't have the
            Resource instances:

                >>> # Make sure parents of `child_a` and `child_b` become
                ...  # children of `grandparent`
                >>> grandparent.add('children',
                ...                 [child_a.relationships['parent']['data'],
                ...                  child_b.relationships['parent']['data']])

            If the plural relationship was previously fetched, it must be
            refetched for the changes to appear.

                >>> parent.add('children', ...)
                >>> parent.fetch('children', force=True)
        """

        self._edit_plural_relationship('post', field, values)

    def remove(self, field, values):
        """ Removes items from a plural relationship. Usage:

                >>> parent = Parent.get(XXX)
                >>> child_a, child_b = Child.list(
                ...     filters={'parent': parent.id},
                ... )[:2]
                >>> parent.remove('children', [child_a, child_b])

            Also works with resource identifiers in case we don't have the
            Resource instances:

                >>> # Make sure parents of `child_a` and `child_b` are no
                ... # longer children of `grandparent`
                >>> grandparent.remove(
                ...     'children',
                ...     [child_a.relationships['parent']['data'],
                ...      child_b.relationships['parent']['data']]
                ... )

            If the plural relationship was previously fetched, it must be
            refetched for the changes to appear.

                >>> parent.remove('children', ...)
                >>> parent.fetch('children', force=True)
        """

        self._edit_plural_relationship('delete', field, values)

    def reset(self, field, values):
        """ Completely rewrites a plural relationship. Usage:

                >>> parent = Parent.get(XXX)
                >>> child_a, child_b, child_c = Child.list()[:3]
                >>> assert (child_a.relationships['parent']['data']['id'] ==
                ...         parent.id)
                >>> assert (child_b.relationships['parent']['data']['id'] !=
                ...         parent.id)
                >>> assert (child_c.relationships['parent']['data']['id'] !=
                ...         parent.id)

                >>> parent.reset('children', [child_b, child_c])

            If the plural relationship was previously fetched, it must be
            refetched for the changes to appear.

                >>> parent.reset('children', ...)
                >>> parent.fetch('children', force=True)
        """

        self._edit_plural_relationship('patch', field, values)

    def _edit_relationship(self, method, field, value):
        url = self.relationships[field].\
            get('links', {}).\
            get('self',
                "/{}/{}/relationships/{}".format(self.TYPE, self.id, field))
        self.API.request(method, url, json={'data': value})

    def _edit_plural_relationship(self, method, field, values):
        payload = [self.API.as_resource(item).as_resource_identifier()
                   for item in values]
        self._edit_relationship(method, field, payload)

    # Bulk actions
    @classmethod
    def bulk_delete(cls, items):
        """ Delete API resource instances in bulk. The server needs to support
            this using the 'bulk' profile with the
            'application/vnd.api+json;profile="bulk"' Content-Type header.

            Accepts a list of:

              - Full JSON responses representing a resource object
              - Resource objects
              - Resource identifiers
              - Relationships
              - IDs

            Doesn't return anything, but will raise an exception if something
            went wrong.

            Usage:

                >>> foos = Foo.list(...)
                >>> Foo.bulk_delete(foos)
        """

        payload = []

        for item in items:
            item = cls.as_resource(item)
            if not is_resource(item):
                item = cls(id=item)
            payload.append(item.as_resource_identifier())

        cls.API.request('delete',
                        cls.get_collection_url(),
                        json={'data': payload},
                        bulk=True)
        return len(payload)

    @classmethod
    def bulk_create(cls, items):
        """ Create API resource instances in bulk. The server needs to support
            this using the 'bulk' profile with the
            'application/vnd.api+json;profile="bulk"' Content-Type header.

            Accepts a list of:
                - (Unsaved) API resource instances
                - Dictionaries with (optional) 'attributes' and 'relationships'
                  fields
                - 2-tuples of 'attributes', 'relationships'
                - 'attributes'

            Returns a list of the created instances.

            Usage:

                >>> # Only attributes
                >>> result = Foo.bulk_create([{'username': "username1"},
                ...                           {'username': "username2"},
                ...                           {'username': "username3"}])
                >>> result[0].id
                <<< 1
                >>> result[0].attributes['username']
                <<< 'username1'

                >>> # attributes and relationships
                >>> parent = ...
                >>> result = Child.bulk_create([({'username': "username1"},
                ...                              {'parent': parent}),
                ...                             ...])
        """

        payload = []
        for item in items:
            if is_list(item):
                attributes, relationships = item
                item = cls(attributes=attributes, relationships=relationships)
            else:
                item = cls.as_resource(item)
                if not isinstance(item, Resource):
                    item = cls(attributes=item)

            payload.append({'type': cls.TYPE})
            if item.attributes:
                payload[-1]['attributes'] = item.attributes
            if item.relationships:
                payload[-1]['relationships'] = item.relationships
            if item.id:
                payload[-1]['id'] = item.id

        response_body = cls.API.request('post',
                                        cls.get_collection_url(),
                                        json={'data': payload},
                                        bulk=True)
        return Collection.from_data(cls.API, response_body)

    @classmethod
    def bulk_update(cls, items, fields=None):
        """ Update API resource instances in bulk. The server needs to support
            this using the 'bulk' profile with the
            'application/vnd.api+json;profile="bulk"' Content-Type header.

            Accepts a list of:
                - API resource instances (with IDs)
                - Dictionaries with (optional) 'attributes', 'relationships'
                  and (required) 'id' fields
                - 3-tuples of 'id', 'attributes', 'relationships'
                - 2-tuples of 'id', 'attributes'
                - 'ids' (maybe we just want the server to update a timestamp)

            Returns a list of the updated instances.

            Usage:

                >>> foos = Foo.list(...)
                >>> for foo in foos:
                ...     foo.attributes['approved'] = True
                >>> foos = Foo.bulk_update(foos, ['approved'])
        """

        if fields is None:
            fields = cls.EDITABLE

        payload = []
        for item in items:

            if is_list(item):
                try:
                    id, attributes, relationships = item
                except ValueError:
                    id, attributes = item
                    relationships = None
                item = cls(id=id,
                           attributes=attributes,
                           relationships=relationships)
            else:
                item = cls.as_resource(item)
                if not isinstance(item, Resource):
                    item = cls(id=item)

            if item.id is None:
                raise ValueError("'id' not supplied as part of an update "
                                 "operation")

            attributes, relationships = item.attributes, item.relationships
            if fields:
                if attributes is not None:
                    attributes = {key: value
                                  for key, value in attributes.items()
                                  if key in fields}
                if relationships is not None:
                    relationships = {key: value
                                     for key, value in relationships.items()
                                     if key in fields}

            payload.append(item.as_resource_identifier())
            if attributes:
                payload[-1]['attributes'] = attributes
            if relationships:
                payload[-1]['relationships'] = {
                    key: cls.API.as_resource(value).as_relationship()
                    for key, value in relationships.items()
                }

        response_body = cls.API.request('patch',
                                        cls.get_collection_url(),
                                        json={'data': payload},
                                        bulk=True)
        return Collection.from_data(cls.API, response_body)

    # Utils
    def __eq__(self, other):
        other = self.API.as_resource(other)
        return self.as_resource_identifier() == other.as_resource_identifier()

    def __repr__(self):
        if self.__class__ is Resource:
            class_name = "Unknown Resource"
        else:
            class_name = self.__class__.__name__

        if self.id is not None:
            details = self.id
        else:
            details = "Unsaved"

        if self.redirect is not None:
            details += " (redirect ready)"

        if not self.attributes and not self.relationships:
            details += " (Unfetched)"

        return repr("<{}: {}>".format(class_name, details))

    def __copy__(self):
        # Will eventually call `_overwrite` so `deepcopy` will be used
        relationships = deepcopy(self.relationships)
        relationships.update(self.related)
        return self.__class__(id=self.id, attributes=self.attributes,
                              relationships=relationships, links=self.links,
                              redirect=self.redirect)

    def as_resource_identifier(self):
        return {'type': self.TYPE, 'id': self.id}

    def as_relationship(self):
        return {'data': self.as_resource_identifier()}

    @classmethod
    def get_collection_url(cls):
        return "/{}".format(cls.TYPE)

    def get_item_url(self):
        if 'self' in self.links:
            return self.links['self']
        else:
            return "/{}/{}".format(self.TYPE, self.id)
