import json
import os

from ansible.module_utils.basic import AnsibleModule
from inspect import getmembers, ismethod, isfunction, getargspec
import requests
import six

try:
    from girder_client import GirderClient, AuthenticationError
    HAS_GIRDER_CLIENT = True
except ImportError:
    HAS_GIRDER_CLIENT = False

try:
    from girder.utility.s3_assetstore_adapter import DEFAULT_REGION
except ImportError:
    DEFAULT_REGION = 'us-east-1'

__version__ = '0.3.0'

DOCUMENTATION = """
---
module: girder
author: "Kitware, Inc. <kitware@kitware.com>
version_added: "0.1"
short_description: A module that wraps girder_client
requirements: [ girder_client==1.1.0 ]
description:
   - Manage a girder instance using the RESTful API
options:
    host:
        required: false
        default: 'localhost'
        description:
            - domain or IP of the host running girder
    port:
        required: false
        default: '80' for http, '443' for https
        description:
            - port the girder instance is running on

    apiRoot:
        required: false
        default: '/api/v1'
        description:
            - path on server corresponding to the root of Girder REST API

    apiUrl:
        required: false
        default: None
        description:
            - full URL base of the girder instance API
    apiKey:
        required: false
        default: None
        description:
            - pass in an apiKey instead of username/password


    scheme:
        required: false
        default: 'http'
        description:
            - A string containing the scheme for the Girder host

    dryrun:
        required: false
        default: None (passed through)
        description:
            - See GirderClient.__init__()

    blacklist:
        required: false
        default: None (passed through)
        description:
            - See GirderClient.__init__()

    username:
        required: true
        description:
            - Valid username for the system
            - Required with password
            - must be specified if 'token' is not specified
                - (See note on 'user')

    password:
        required: true
        description:
            - Valid password for the system
            - Required with username
            - must be specified if 'token' is not specified
                - (See note on 'user')
    token:
        required: true
        description:
            - A girder client token
            - Can be retrieved by accessing the accessing the 'token' attribute
              from a successfully authenticated call to girder in a previous
              task.
            - Required if 'username' and 'password' are not specified
                - (See note on 'user')
    state:
        required: false
        default: "present"
        choices: ["present", "absent"]
        description:
            - Used to indicate the presence or absence of a resource
              - e.g.,  user, assetstore

    user:
        required: false
        description:
            - If using the 'user' task, you are NOT REQUIRED to pass in a
              'username' & 'password',  or a 'token' attributes. This is because
              the first user created on an fresh install of girder is
              automatically made an administrative user. Once you are certain
              you have an admin user you should use those credentials in all
              subsequent tasks that use the 'user' task.

            - Takes a mapping of key value pairs
              options:
                  login:
                      required: true
                      description:
                          - The login name of the user
                  password:
                      required: true
                      description:
                          - The password of the user

                  firstName:
                      required: false
                      default: pass through to girder client
                      description:
                          - The first name of the user

                  lastName:
                      required: false
                      default: pass through to girder client
                      description:
                          - The last name of the user
                  email:
                      required: false
                      default: pass through to girder client
                      description:
                          - The email of the user
                  admin:
                      required: false
                      default: false
                      description:
                          - If true,  make the user an administrator.


    assetstore:
        required: false
        description:
            - Specifies an assetstore
            - Takes many options depending on 'type'
              options:
                  name:
                      required: true
                      description:
                          - Name of the assetstore
                  type:
                      required: true
                      choices: ['filesystem', 'gridfs', 's3', 'hdfs', 'database']
                      description:
                          - Currently only 'filesystem' has been tested
                  readOnly:
                      required: false
                      default: false
                      description:
                          - Should the assetstore be read only?
                  current:
                      required: false
                      default: false
                      description:
                          - Should the assetstore be set as the current
                            assetstore?

              options (filesystem):
                  root:
                      required: true
                      description:
                          -  Filesystem path to the assetstore

              options (gridfs) (EXPERIMENTAL):
                   db:
                       required: true
                       description:
                           - database name
                   mongohost:
                       required: true
                       description:
                           - Mongo host URI

                   replicaset:
                       required: false
                       default: ''
                       description:
                           - Replica set name

              options (s3) (EXPERIMENTAL):
                   bucket:
                       required: true
                       description:
                           - The S3 bucket to store data in

                   prefix:
                       required: false
                       description:
                           - Optional path prefix within the bucket under which
                             files will be stored

                   accessKeyId:
                       required: false
                       description:
                           - the AWS access key ID to use for authentication

                   secret:
                       required: false
                       description:
                           - the AWS secret key to use for authentication

                   service:
                       required: false
                       default: ''
                       description:
                           - The S3 service host (for S3 type)
                           - Only set this if you're not using AWS S3.
                           - This can be used to specify a protocol and port
                             -  use the form [http[s]://](host domain)[:(port)]
                           - Do not include the bucket name here

                   region:
                       required: false
                       default: us-east

                   inferCredentials:
                       required: false
                       default: false

              options (hdfs) (EXPERIMENTAL):
                   host:
                       required: true
                       description:
                           - None
                   port:
                       required: true
                       description:
                           - None
                   path:
                       required: true
                       description:
                           - None
                   user:
                       required: true
                       description:
                           - None
                   webHdfsPort
                       required: true
                       description:
                           - None

    group:
        required: false
        description:
            - Create a group with pre-existing users
        options:
            name:
                required: true
                description:
                    - Name of the group

            description:
                required: false
                description:
                    - Description of the group
            users:
                required: false
                type: list
                description:
                    - List of dicts with users login and their level
                options:
                    login:
                        required: true
                        description:
                            - the login name
                    type:
                        required: true
                        choices: ["member", "moderator", "admin"]
                        description:
                            - Access level for that user in the group

    collection:
        required: false
        description:
            - Create a collection
        options:
            name:
                required: true
                description:
                    - Name of the collection

            description:
                required: false
                description:
                    - Description of the collection

            folders:
                required: false
                description:
                    - A list of folder options
                    - Specified by the 'folder' option to the girder module
                    - (see 'folder:')
            public:
                required: false
                description:
                    - Set to true if the collection is public or false if private
            access:
                required: false
                description:
                    - Set the access for the collection/folder
                options:
                    users:
                        required: false
                        description:
                            - list of login/type arguments
                            - login is a user login
                            - type is one of 'admin', 'moderator', 'member'
                    groups:
                        required: false
                        description:
                            - list of name/type arguments
                            - name is a group name
                            - type is one of 'admin', 'moderator', 'member'

    folder:
        required: false
        description:
            - Create a folder
        options:
            name:
                required: true
                description:
                    - Name of the folder

            description:
                required: false
                description:
                    - Description of the folder
            parentType:
                required: true
                choices: ["user", "folder", "collection"]
                description:
                    - The type of the parent
            parentId:
                required: true
                description:
                    - The ID of the parent collection/folder/user
            folders:
                required: false
                description:
                    - A list of folder options
                    - Specified by the 'folder' option to the girder module
                    - (see 'folder:')
            public:
                required: false
                description:
                    - Set to true if the folder is public or false if private
            access:
                required: false
                description:
                    - Set the access for the collection/folder
                options:
                    users:
                        required: false
                        description:
                            - list of login/type arguments
                            - login is a user login
                            - type is one of 'admin', 'moderator', 'member'
                    groups:
                        required: false
                        description:
                            - list of name/type arguments
                            - name is a group name
                            - type is one of 'admin', 'moderator', 'member'

    item:
        required: false
        description:
            - Create a item
        options:
            name:
                required: true
                description:
                    - Name of the item

            description:
                required: false
                description:
                    - Description of the item
            folderId:
                required: true
                description:
                    - The ID of the parent collection

     files:
        required: false
        description:
            - Uploads a list of files to an item
        options:
            itemId:
                required: true
                description:
                    - the parent item for the file
            sources:
                required: true
                description:
                    - list of local file paths
                    - files will be uploaded to the item

     setting:
        required: false
        description:
            - Get/set the values of system settings
        options:
            key:
                required: true
                description:
                    - The key identifying this setting
            value:
                required: true if state = present, else false
                description:
                    - The value to set
"""

EXAMPLES = """


#############
# Example using 'user'
###


# Ensure "admin" user exists
- name: Create 'admin' User
  girder:
    user:
      firstName: "John"
      lastName: "Doe"
      login: "admin"
      password: "letmein"
      email: "john.due@test.com"
      admin: yes
    state: present

# Ensure a 'foobar' user exists
- name: Create 'foobar' User
  girder:
    username: "admin"
    password: "letmein"
    user:
      firstName: "Foo"
      lastName: "Bar"
      login: "foobar"
      password: "foobarbaz"
      email: "foo.bar@test.com"
      admin: yes
    state: present

# Remove the 'foobar' user
- name: Remove 'foobar' User
  username: "admin"
  password: "letmein"
  girder:
    user:
      login: "foobar"
      password: "foobarbaz"
    state: absent

############
# Examples using Group
#

# Create an 'alice' user
- name: Create 'alice' User
  girder:
    port: 8080
    username: "admin"
    password: "letmein"
    user:
      firstName: "Alice"
      lastName: "Doe"
      login: "alice"
      password: "letmein"
      email: "alice.doe@test.com"
    state: present

# Create a 'bill' user
- name: Create 'bill' User
  girder:
    port: 8080
    username: "admin"
    password: "letmein"
    user:
      firstName: "Bill"
      lastName: "Doe"
      login: "bill"
      password: "letmein"
      email: "bill.doe@test.com"
    state: present

# Create a 'chris' user
- name: Create 'chris' User
  girder:
    port: 8080
    username: "admin"
    password: "letmein"
    user:
      firstName: "Chris"
      lastName: "Doe"
      login: "chris"
      password: "letmein"
      email: "chris.doe@test.com"
    state: present

- name: Create a test group with users
  girder:
    port: 8080
    username: "admin"
    password: "letmein"
    group:
      name: "Test Group"
      description: "Basic test group"
      users:
        - login: alice
          type: member
        - login: bill
          type: moderator
        - login: chris
          type: admin
    state: present

# Remove Bill from the group,
# Note that 'group' list is idempotent - it describes the desired state

- name: Remove bill from group
  girder:
    port: 8080
    username: "admin"
    password: "letmein"
    group:
      name: "Test Group"
      description: "Basic test group"
      users:
        - login: alice
          type: member
        - login: chris
          type: admin
    state: present


############
# Filesystem Assetstore Tests
#

- name: Create filesystem assetstore
  girder:
    username: "admin"
     password: "letmein"
     assetstore:
       name: "Temp Filesystem Assetstore"
       type: "filesystem"
       root: "/data/"
       current: true
     state: present

- name: Delete filesystem assetstore
  girder:
    username: "admin"
    password: "letmein"
    assetstore:
      name: "Temp Filesystem Assetstore"
      type: "filesystem"
      root: "/tmp/"
    state: absent


############
# Examples using collections, folders, items and files
#

# Creates a test collection called "Test Collection"
- name: Create collection
  girder:
    port: 8080
    username: "admin"
    password: "letmein"
    collection:
      name: "Test Collection"
      description: "A test collection"
  register: test_collection

# Creates a folder called "test folder" under "Test Collection"
- name: Create folder
  girder:
    port: 8080
    username: "admin"
    password: "letmein"
    folder:
      parentType: "collection"
      parentId: "{{test_collection['gc_return']['_id'] }}"
      name: "test folder"
      description: "A test folder"
  register: test_folder

# Creates an item called "test item" under "test folder"
- name: Create an item
  girder:
    port: 8080
    username: "admin"
    password: "letmein"
    item:
      folderId: "{{test_folder['gc_return']['_id'] }}"
      name: "test item"
      description: "A test item"
  register: test_item

# Upload files on the localhost at /tmp/data/test1.txt and
# /tmp/data/test2.txt to the girder instance under the item
# "test item"
# Note:  the list is idempotent and will remove files that are
# not listed under the item. Files are checked for both name
# and size to determine if they should be updated.
- name: Upload files
  girder:
    port: 8080
    username: "admin"
    password: "letmein"
    files:
      itemId: "{{ test_item['gc_return']['_id'] }}"
      sources:
        - /tmp/data/test1.txt
        - /tmp/data/test2.txt
  register: retval


############
# Examples Using collection/folder hierarchy
#

- name: Create collection with a folder and a subfolder
  girder:
    port: 8080
    username: "admin"
    password: "letmein"
    collection:
      name: "Test Collection"
      description: "A test collection"
      folders:
        - name: "test folder"
          description: "A test folder"
          folders:
            - name: "test subfolder"
            - name: "test subfolder 2"
  register: test_collection



############
# Examples Setting access to files/folders
#


- name: Create collection with access
  girder:
    port: 8080
    username: "admin"
    password: "letmein"
    collection:
      name: "Test Collection"
      description: "A test collection"
      public: no
      access:
        users:
          - login: alice
            type: admin
          - login: chris
            type: member
  register: test_collection


- name: Add group to Test Collection
  girder:
    port: 8080
    username: "admin"
    password: "letmein"
    collection:
      name: "Test Collection"
      description: "A test collection"
      public: no
      access:
        users:
          - login: alice
            type: admin
          - login: bill
            type: moderator
          - login: chris
            type: member
        groups:
          - name: Test Group
            type: member
  register: test_collection

- name: Add Test Folder with access
  girder:
    port: 8080
    username: "admin"
    password: "letmein"
    folder:
      parentType: "collection"
      parentId: "{{test_collection['gc_return']['_id'] }}"
      name: "test folder"
      description: "A test folder"
      access:
        users:
          - login: bill
            type: admin
        groups:
          - name: Test Group
            type: member
  register: test_folder



############
# Examples using get
#


# Get my info
- name: Get users from http://localhost:80/api/v1/users
  girder:
    username: 'admin'
    password: 'letmein'
    get:
      path: "users"
    register: ret_val

# Prints debugging messages with the emails of the users
# From the last task by accessing 'gc_return' of the registered
# variable 'ret_val'
- name: print emails of users
  debug: msg="{{ item['email'] }}"
  with_items: "{{ ret_val['gc_return'] }}"


#############
# Advanced usage
#

# Supports get, post, put, delete methods,  but does
# not guarantee idempotence on these methods!

- name: Run a system check
  girder:
    username: "admin"
    password: "letmein"
    put:
      path: "system/check"

# An example of posting an item to Girder
# Note that this is NOT idempotent. Running
# multiple times will create "An Item", "An Item (1)",
# "An Item (2)", etc..

- name: Get Me
  girder:
    username: "admin"
    password: "letmein"
    get:
      path: "user/me"
  register: ret

# Show use of 'token' for subsequent authentication
- name: Get my public folder
  girder:
    token: "{{ ret['token'] }}"
    get:
      path: "folder"
      parameters:
        parentType: "user"
        parentId: "{{ ret['gc_return']['_id'] }}"
        text: "Public"
  register: ret


- name: Post an item to my public folder
  girder:
    host: "data.kitware.com"
    scheme: 'https'
    token: "{{ ret['token'] }}"
    post:
      path: "item"
      parameters:
        folderId: "{{ ret['gc_return'][0]['_id'] }}"
        name: "An Item"


"""


def unjsonify(a):
    """Convert json parts to python objects.

    Tries to convert a json string or a compund object consisting of partial
    json strings to a full python object.  Returns either a json scalar (string,
    int, float), a list or dict of json scalars, or any combination of lists and
    dicts that eventually end at json scalars.

    Note that this function does not detect cycles in an object graph and, if
    provided an object with one, will run until out of memory, at the stack
    limit, or until at some other run-time limit.
    """
    # if string, try to loads() it
    if isinstance(a, six.string_types):
        try:
            a = json.loads(a)
            # pass-through to below
        except ValueError:
            return a

    if isinstance(a, list):
        return [unjsonify(x) for x in a]

    if isinstance(a, dict):
        return {str(k): unjsonify(v) for k, v in a.items()}

    return None


def class_spec(cls, include=None):
    include = include if include is not None else []

    for fn, method in getmembers(cls, predicate=lambda f: ismethod(f) or isfunction(f)):
        if fn in include:
            spec = getargspec(method)
            # Note: must specify the kind of data we accept
            #       In all most all cases this will be a dict
            #       where variable names become keys used in yaml
            #       but if we have a vararg then we need to set
            #       this to a list.
            kind = 'dict' if spec.varargs is None else 'list'

            # spec.args[1:] so we don't include 'self'
            params = spec.args[1:]
            d = len(spec.defaults) if spec.defaults is not None else 0
            r = len(params) - d

            yield (fn, {'required': params[:r],
                        'optional': params[r:],
                        'type': kind})


class Resource(object):
    known_resources = ['collection', 'folder', 'item', 'group']

    def __init__(self, client, resource_type):
        self._resources = None
        self._resources_by_name = None
        self.client = client

        if resource_type in self.known_resources:
            self.resource_type = resource_type
        else:
            raise Exception('{} is an unknown resource!'.format(resource_type))

    @property
    def resources(self):
        if self._resources is None:
            self._resources = {r['_id']: r for r
                               in self.client.get(self.resource_type)}
        return self._resources

    @property
    def resources_by_name(self):
        if self._resources_by_name is None:
            self._resources_by_name = {r['name']: r
                                       for r in self.resources.values()}

        return self._resources_by_name

    def __apply(self, _id, func, *args, **kwargs):
        if _id in self.resources.keys():
            ret = func('{}/{}'.format(self.resource_type, _id),
                       *args, **kwargs)
            self.client.changed = True
        return ret

    def id_exists(self, _id):
        return _id in self.resources.keys()

    def name_exists(self, _name):
        return _name in self.resources_by_name.keys()

    def create(self, body, **kwargs):
        try:
            ret = self.client.post(self.resource_type, body, **kwargs)
            self.client.changed = True
        except requests.HTTPError as htErr:
            try:
                # If we can't create the item,  try and return
                # The item with the same name
                ret = self.resources_by_name[kwargs['name']]
            except KeyError:
                raise htErr
        return ret

    def read(self, _id):
        return self.resources[_id]

    def read_by_name(self, name):
        return self.resources_by_name[name]['_id']

    def update(self, _id, body, **kwargs):
        if _id in self.resources:
            current = self.resources[_id]
            # if body is a subset of current we don't actually need to update
            try:
                if set(body.items()) <= set(
                        {k: v for k, v in current.items() if k in body}.items()):
                    return current
            except TypeError:
                # If a current value is unhashable, we throw a TypeError, but
                # should still update it.
                pass
            return self.__apply(_id, self.client.put, body, **kwargs)
        else:
            raise Exception('{} does not exist!'.format(_id))

    def update_by_name(self, name, body, **kwargs):
        return self.update(self.resources_by_name[name]['_id'],
                           body, **kwargs)

    def delete(self, _id):
        return self.__apply(_id, self.client.delete)

    def delete_by_name(self, name):
        try:
            return self.delete(self.resources_by_name[name]['_id'])
        except KeyError:
            return {}


class AccessMixin(object):

    def get_access(self, _id):
        return self.client.get('{}/{}/access'
                               .format(self.resource_type, _id))

    def put_access(self, _id, access, public=True):
        current_access = self.get_access(_id)

        if set([tuple(u.values()) for u in access['users']]) ^ \
           set([(u['id'], u['level']) for u in current_access['users']]):
            self.client.changed = True

        if set([tuple(g.values()) for g in access['groups']]) ^ \
           set([(u['id'], u['level']) for u in current_access['groups']]):
            self.client.changed = True

        return self.client.put('{}/{}/access'
                               .format(self.resource_type, _id),
                               dict(access=json.dumps(access),
                                    public='true' if public else 'false'))


class CollectionResource(AccessMixin, Resource):
    def __init__(self, client):
        super(CollectionResource, self).__init__(client, 'collection')


class GroupResource(Resource):
    def __init__(self, client):
        super(GroupResource, self).__init__(client, 'group')


class FolderResource(AccessMixin, Resource):
    def __init__(self, client, parentType, parentId):
        super(FolderResource, self).__init__(client, 'folder')
        self.parentType = parentType
        self.parentId = parentId

    @property
    def resources(self):
        if self._resources is None:
            self._resources = {r['_id']: r for r
                               in self.client.get(self.resource_type, {
                                   'parentType': self.parentType,
                                   'parentId': self.parentId
                               })}
            # parentType is stored as parrentCollection in database
            # We need parentType to be available so we can do set
            # comparison to check if we are updating parentType (e.g.
            # Moving a subfolder from a folder to a collection)
            for _id in self._resources.keys():
                self._resources[_id]['parentType'] = \
                    self._resources[_id]['parentCollection']
        return self._resources


class ItemResource(Resource):
    def __init__(self, client, folderId):
        super(ItemResource, self).__init__(client, 'item')
        self.folderId = folderId

    @property
    def resources(self):
        if self._resources is None:
            self._resources = {r['_id']: r for r
                               in self.client.get(self.resource_type, {
                                   'folderId': self.folderId
                               })}
        return self._resources


class GirderClientModule(GirderClient):

    # Exclude these methods from both 'raw' mode
    _include_methods = ['get', 'put', 'post', 'delete', 'patch',
                        'user', 'assetstore',
                        'collection', 'folder', 'item', 'files',
                        'group', 'setting']

    _debug = True

    def exit(self):
        if not self._debug:
            del self.message['debug']

        self.module.exit_json(changed=self.changed, **self.message)

    def fail(self, msg):
        self.module.fail_json(msg=msg)

    def __init__(self):
        self.changed = False
        self.message = {'msg': 'Success!', 'debug': {}}

        self.spec = dict(class_spec(self.__class__,
                                    GirderClientModule._include_methods))
        self.required_one_of = self.spec.keys()

        # Note: if additional types are added o girder this will
        # have to be updated!
        self.access_types = {'member': 0, 'moderator': 1, 'admin': 2}

    def __call__(self, module):
        self.module = module

        super(GirderClientModule, self).__init__(
            **{p: self.module.params[p] for p in
               ['host', 'port', 'apiRoot', 'apiUrl',
                'scheme', 'dryrun', 'blacklist']
               if module.params[p] is not None})
        # If a username and password are set
        if self.module.params['username'] is not None:
            try:
                self.authenticate(
                    username=self.module.params['username'],
                    password=self.module.params['password'])

            except AuthenticationError:
                self.fail('Could not Authenticate!')

        elif self.module.params['apiKey'] is not None:
            try:
                self.authenticate(
                    apiKey=self.module.params['apiKey'])

            except AuthenticationError:
                self.fail('Could not Authenticate!')

        # If a token is set
        elif self.module.params['token'] is not None:
            self.token = self.module.params['token']

        # Else error if we're not trying to create a user
        elif self.module.params['user'] is None:
            self.fail('Must pass in either username & password, '
                      'or a valid girder_client token')

        self.message['token'] = self.token

        for method in self.required_one_of:
            if self.module.params[method] is not None:
                self.__process(method)
                self.exit()

        self.fail('Could not find executable method!')

    def __process(self, method):
        # Parameters from the YAML file
        params = self.module.params[method]
        # Final list of arguments to the function
        args = []
        # Final list of keyword arguments to the function
        kwargs = {}

        if isinstance(params, dict):
            for arg_name in self.spec[method]['required']:
                if arg_name not in params.keys():
                    self.fail('%s is required for %s' % (arg_name, method))
                args.append(params[arg_name])

            for kwarg_name in self.spec[method]['optional']:
                if kwarg_name in params.keys():
                    kwargs[kwarg_name] = params[kwarg_name]

        elif isinstance(params, list):
            args = params
        else:
            args = [params]

        ret = getattr(self, method)(*args, **kwargs)

        self.message['debug']['method'] = method
        self.message['debug']['args'] = args
        self.message['debug']['kwargs'] = kwargs
        self.message['debug']['params'] = params

        self.message['gc_return'] = ret

    def files(self, itemId, sources=None):
        ret = {'added': [],
               'removed': []}

        files = self.get('item/{}/files'.format(itemId))

        if self.module.params['state'] == 'present':

            file_dict = {f['name']: f for f in files}

            source_dict = {os.path.basename(s): {
                'path': s,
                'name': os.path.basename(s),
                'size': os.path.getsize(s)} for s in sources}

            source_names = set([(s['name'], s['size'])
                                for s in source_dict.values()])

            file_names = set([(f['name'], f['size'])
                              for f in file_dict.values()])

            for n, _ in (file_names - source_names):
                self.delete('file/{}'.format(file_dict[n]['_id']))
                ret['removed'].append(file_dict[n])

            for n, _ in (source_names - file_names):
                self.uploadFileToItem(itemId, source_dict[n]['path'])
                ret['added'].append(source_dict[n])

        elif self.module.params['state'] == 'absent':
            for f in files:
                self.delete('file/{}'.format(f['_id']))
                ret['removed'].append(f)

        if len(ret['added']) != 0 or len(ret['removed']) != 0:
            self.changed = True

        return ret

    def _get_user_by_login(self, login):
        try:
            user = self.get('/resource/lookup',
                            {'path': '/user/{}'.format(login)})
        except requests.HTTPError:
            user = None
        return user

    def _get_group_by_name(self, name):
        try:
            # Could potentially fail if we have more 50 groups
            group = {g['name']: g for g in self.get('group')}['name']
        except (KeyError, requests.HTTPError):
            group = None
        return group

    def group(self, name, description, users=None, debug=False):

        r = GroupResource(self)
        valid_fields = [('name', name),
                        ('description', description)]

        if self.module.params['state'] == 'present':
            if r.name_exists(name):
                ret = r.update_by_name(name, {k: v for k, v in valid_fields
                                              if v is not None})
            else:
                ret = r.create({k: v for k, v in valid_fields
                                if v is not None})

            if users is not None:
                ret['added'] = []
                ret['removed'] = []
                ret['updated'] = []

                group_id = ret['_id']

                # Validate and normalize the user list
                for user in users:
                    assert 'login' in user.keys(), \
                        'User list must have a login attribute'

                    user['type'] = self.access_types.get(
                        user.get('type', 'member'), 'member')

                # dict of passed in login -> type
                user_levels = {u['login']: u['type'] for u in users}

                # dict of current login -> user information for this group
                members = {m['login']: m for m in
                           self.get('group/{}/member'.format(group_id))}

                # Add these users
                for login in (set(user_levels.keys()) - set(members.keys())):
                    user = self._get_user_by_login(login)
                    if user is not None:
                        # add user at level
                        self.post('group/{}/invitation'.format(group_id),
                                  {'userId': user['_id'],
                                   'level': user_levels[login],
                                   'quiet': True,
                                   'force': True})
                        ret['added'].append(user)
                    else:
                        raise Exception('{} is not a valid login!'
                                        .format(login))

                # Remove these users
                for login in (set(members.keys()) - set(user_levels.keys())):
                    self.delete('/group/{}/member'.format(group_id),
                                {'userId': members[login]['_id']})
                    ret['removed'].append(members[login])

                # Set of users that potentially need to be updated
                if len(set(members.keys()) & set(user_levels.keys())):
                    group_access = self.get('group/{}/access'.format(group_id))
                    # dict of current login -> access information for this group
                    user_access = {m['login']: m
                                   for m in group_access['access']['users']}

                    # dict of login -> level for the current group
                    # Note:
                    #  Here we join members with user_access - if the member
                    #  is not in user_access then the member has a level of 0 by
                    #  default. This gives us  a complete list of every login,
                    #  and its access level, including those that are IN the
                    #  group, but have no permissions ON the group.
                    member_levels = {m['login']:
                                     user_access.get(m['login'],
                                                     {'level': 0})['level']
                                     for m in members.values()}

                    ret = self._promote_or_demote_in_group(ret,
                                                           member_levels,
                                                           user_levels,
                                                           group_id)

                # Make sure 'changed' is handled correctly if we've
                # manipulated the group's users in any way
                if (len(ret['added']) != 0 or len(ret['removed']) != 0
                        or len(ret['updated']) != 0):
                    self.changed = True

        elif self.module.params['state'] == 'absent':
            ret = r.delete_by_name(name)

        return ret

    def _promote_or_demote_in_group(self, ret, member_levels, user_levels,
                                    group_id):
        """Promote or demote a set of users.

        :param ret: the current dict of return values
        :param members_levels: the current access levels of each member
        :param user_levels: the desired levels of each member
        :param types: a mapping between resource names and access levels
        :returns: info about what has (or has not) been updated
        :rtype: dict

        """
        reverse_type = {v: k for k, v in self.access_types.items()}

        for login in (set(member_levels.keys())
                      & set(user_levels.keys())):
            user = self._get_user_by_login(login)
            _id = user['_id']

            # We're promoting
            if member_levels[login] < user_levels[login]:
                resource = reverse_type[user_levels[login]]
                self.post('group/{}/{}'
                          .format(group_id, resource),
                          {'userId': _id})

                user['from_level'] = member_levels[login]
                user['to_level'] = user_levels[login]
                ret['updated'].append(user)

            # We're demoting
            elif member_levels[login] > user_levels[login]:
                resource = reverse_type[member_levels[login]]
                self.delete('group/{}/{}'
                            .format(group_id, resource),
                            {'userId': _id})

                # In case we're not demoting to member make sure
                # to update to promote to whatever level we ARE
                # demoting too now that our user is a only a member
                if user_levels[login] != 0:
                    resource = reverse_type[user_levels[login]]
                    self.post('group/{}/{}'
                              .format(group_id, resource),
                              {'userId': _id})

                    user['from_level'] = member_levels[login]
                    user['to_level'] = user_levels[login]
                    ret['updated'].append(user)

        return ret

    def item(self, name, folderId, description=None, files=None,
             access=None, debug=False):
        ret = {}
        r = ItemResource(self, folderId)
        valid_fields = [('name', name),
                        ('description', description),
                        ('folderId', folderId)]

        if self.module.params['state'] == 'present':
            if r.name_exists(name):
                ret = r.update_by_name(name, {k: v for k, v in valid_fields
                                              if v is not None})
            else:
                ret = r.create({k: v for k, v in valid_fields
                                if v is not None})
        # handle files here

        elif self.module.params['state'] == 'absent':
            ret = r.delete_by_name(name)

        return ret

    def folder(self, name, parentId, parentType, description=None,
               public=True, folders=None, access=None, debug=False):

        ret = {}

        assert parentType in ['collection', 'folder', 'user'], \
            'parentType must be collection or folder'

        r = FolderResource(self, parentType, parentId)
        valid_fields = [('name', name),
                        ('description', description),
                        ('parentType', parentType),
                        ('parentId', parentId)]

        if self.module.params['state'] == 'present':
            if r.name_exists(name):
                ret = r.update_by_name(name, {k: v for k, v in valid_fields
                                              if v is not None})
            else:
                valid_fields = valid_fields + [('public', public)]
                ret = r.create({k: v for k, v in valid_fields
                                if v is not None})

            if folders is not None:
                self._process_folders(folders, ret['_id'], 'folder')

            # handle access here
            if access is not None:
                _id = ret['_id']
                ret['access'] = self._access(r, access, _id, public=public)

        elif self.module.params['state'] == 'absent':
            ret = r.delete_by_name(name)

        return ret

    def _access(self, r, access, _id, public=True):
        access_list = {'users': [], 'groups': []}
        users = access.get('users', None)
        groups = access.get('groups', None)

        if groups is not None:
            assert set(g['type'] for g in groups if 'type' in g) <= \
                set(self.access_types.keys()), 'Invalid access type!'

            # Hash of name -> group information
            # used to get user id's for access control lists
            all_groups = {g['name']: g for g in self.get('group')}

            access_list['groups'] = [{'id': all_groups[g['name']]['_id'],
                                      'level': self.access_types[g['type']]
                                      if 'type' in g else g['level']}
                                     for g in groups]

        if users is not None:

            assert set(u['type'] for u in users if 'type' in u) <= \
                set(self.access_types.keys()), 'Invalid access type!'

            # Hash of login -> user information
            # used to get user id's for access control lists
            current_users = {u['login']: self._get_user_by_login(u['login'])
                             for u in users}

            access_list['users'] = [{'id': current_users[u['login']]['_id'],
                                     'level': self.access_types[u['type']]
                                     if 'type' in u else u['level']}
                                    for u in users]

        return r.put_access(_id, access_list, public=public)

    def _process_folders(self, folders, parentId, parentType):
        """
        Process a list of folders from a user or collection.

        :param folders: List of folders passed as attribute
                        to user or collection
        :param parentId: ID of the user or the collection
        :param parentType: one of 'user' or 'collection'
        :returns: Nothing
        :rtype: None
        """
        current_folders = {f['name']: f for f in
                           self.get('folder', {'parentType': parentType,
                                               'parentId': parentId})}
        # Add, update or noop listed folders
        for folder in folders:
            # some validation of folder here would be a good idea
            kwargs = folder.copy()
            del kwargs['name']
            self.folder(folder['name'],
                        parentId=parentId,
                        parentType=parentType,
                        **kwargs)

        # Make sure we remove folders not listed
        for name in (set(current_folders.keys())
                     - set([f['name'] for f in folders])):

            original_state = self.module.params['state']
            self.module.params['state'] = 'absent'
            self.folder(name,
                        parentId=parentId,
                        parentType=parentType)
            self.module.params['state'] = original_state

    def collection(self, name, description=None,
                   public=True, access=None, folders=None, debug=False):

        ret = {}
        r = CollectionResource(self)
        valid_fields = [('name', name),
                        ('description', description)]

        if self.module.params['state'] == 'present':
            if r.name_exists(name):
                # While we can set public when we create the collection, we
                # cannot update the public/private status of a collection
                # via the PUT /collection/%s endpoint. Currently this is
                # possible through the API by hitting the
                # PUT /collection/%s/access endpoint with public=true and
                # the access dict equal to {}
                if r.resources_by_name[name]['public'] != public:
                    _id = r.resources_by_name[name]['_id']
                    self.changed = True
                    self._access(r, r.get_access(_id), _id, public=public)
                    # invalidate the resource cache - this forces us to pick up
                    # the change in 'public' attribute despite it not being
                    # an attribute we can modify
                    r._resources = None

                ret = r.update_by_name(name, {k: v for k, v in valid_fields
                                              if v is not None})

            else:
                valid_fields.append(('public', public))
                ret = r.create({k: v for k, v in valid_fields
                                if v is not None})
        if folders is not None:
            self._process_folders(folders, ret['_id'], 'collection')

        if access is not None:
            _id = ret['_id']
            ret['access'] = self._access(r, access, _id, public=public)

        elif self.module.params['state'] == 'absent':
            ret = r.delete_by_name(name)

        return ret

    def user(self, login, password, firstName=None,
             lastName=None, email=None, admin=False, folders=None):

        if self.module.params['state'] == 'present':

            # Fail if we don't have firstName, lastName and email
            for var_name, var in [('firstName', firstName),
                                  ('lastName', lastName), ('email', email)]:
                if var is None:
                    self.fail('%s must be set if state '
                              "is 'present'" % var_name)

            try:
                ret = self.authenticate(username=login,
                                        password=password)

                me = self.get('user/me')

                # List of fields that can actually be updated
                updateable = ['firstName', 'lastName', 'email', 'admin']
                passed_in = [firstName, lastName, email, admin]

                # If there is actually an update to be made
                if set([(k, v) for k, v in me.items() if k in updateable]) ^ \
                   set(zip(updateable, passed_in)):

                    self.put('user/%s' % me['_id'],
                             parameters={
                                 'login': login,
                                 'firstName': firstName,
                                 'lastName': lastName,
                                 'password': password,
                                 'email': email,
                                 'admin': 'true' if admin else 'false'})
                    self.changed = True

                ret = me
            # User does not exist (with this login info)
            except AuthenticationError:
                ret = self.post('user', parameters={
                    'login': login,
                    'firstName': firstName,
                    'lastName': lastName,
                    'password': password,
                    'email': email,
                    'admin': 'true' if admin else 'false'
                })
                self.changed = True

            if folders is not None:
                _id = self.get('resource/lookup',
                               {'path': '/user/{}'.format(login)})['_id']
                self._process_folders(folders, _id, 'user')

        elif self.module.params['state'] == 'absent':
            ret = []
            try:
                ret = self.authenticate(username=login,
                                        password=password)

                me = self.get('user/me')

                self.delete('user/%s' % me['_id'])
                self.changed = True
            # User does not exist (with this login info)
            except AuthenticationError:
                ret = []

        return ret

    # Handles patch correctly by dumping the data as a string before passing
    # it on to requests See:
    # http://docs.python-requests.org/en/master/user/quickstart/#more-complicated-post-requests
    def patch(self, path, parameters=None, data=None):
        super(GirderClientModule, self).patch(path, parameters=parameters,
                                              data=json.dumps(data))

    assetstore_types = {
        'filesystem': 0,
        'gridfs': 1,
        's3': 2,
        'hdfs': 'hdfs',
        'database': 'database'
    }

    def __validate_hdfs_assetstore(self, *args, **kwargs):
        # Check if hdfs plugin is available,  enable it if it isn't
        pass

    def __validate_database_assetstore(self, *args, **kwargs):
        pass

    def assetstore(self, name, type, root=None, db=None, mongohost=None,
                   replicaset='', bucket=None, prefix='', accessKeyId=None,
                   secret=None, service='', host=None,
                   port=None, path=None, user=None, webHdfsPort=None,
                   dbtype=None, dburi=None, readOnly=False, current=False,
                   region=DEFAULT_REGION, inferCredentials=False):

        # Fail if somehow we have an asset type not in assetstore_types
        if type not in self.assetstore_types.keys():
            self.fail('assetstore type %s is not implemented!' % type)

        argument_hash = {
            'filesystem': {'name': name,
                           'type': self.assetstore_types[type],
                           'root': root},
            'gridfs': {'name': name,
                       'type': self.assetstore_types[type],
                       'db': db,
                       'mongohost': mongohost,
                       'replicaset': replicaset},
            's3': {'name': name,
                   'type': self.assetstore_types[type],
                   'bucket': bucket},
            'hdfs': {'name': name,
                     'type': self.assetstore_types[type],
                     'host': host,
                     'port': port,
                     'path': path,
                     'user': user,
                     'webHdfsPort': webHdfsPort},
            'database': {'name': name,
                         'type': self.assetstore_types[type],
                         'dbtype': dbtype,
                         'dburi': dburi}
        }

        # Fail if we don't have all the required attributes
        # for this asset type
        for k, v in argument_hash[type].items():
            if v is None:
                self.fail('assetstores of type '
                          '%s require attribute %s' % (type, k))

        # Set optional arguments in the hash
        argument_hash[type]['readOnly'] = readOnly
        argument_hash[type]['current'] = current
        argument_hash[type]['prefix'] = prefix
        argument_hash[type]['accessKeyId'] = accessKeyId
        argument_hash[type]['secret'] = secret
        argument_hash[type]['service'] = service
        argument_hash[type]['region'] = region
        argument_hash[type]['inferCredentials'] = inferCredentials

        ret = []
        # Get the current assetstores
        assetstores = {a['name']: a for a in self.get('assetstore')}

        self.message['debug']['assetstores'] = assetstores

        # If we want the assetstore to be present
        if self.module.params['state'] == 'present':

            # And the asset store exists
            if name in assetstores.keys():

                id = assetstores[name]['_id']

                ####
                # Fields that could potentially be updated
                #
                # This is necessary because there are fields in the assetstores
                # that do not hash (e.g., capacity) and fields in the
                # argument_hash that are not returned by 'GET' assetstore (e.g.
                # readOnly). We could be more precise about this
                # (e.g., by only checking items that are relevant to this type)
                # but readability suffers.
                updateable = ['root', 'mongohost', 'replicaset', 'bucket',
                              'prefix', 'db', 'accessKeyId', 'secret',
                              'service', 'host', 'port', 'path', 'user',
                              'webHdfsPort', 'current', 'dbtype', 'dburi',
                              'region', 'inferCredentials']

                # tuples of (key,  value) for fields that can be updated
                # in the assetstore
                assetstore_items = set((k, assetstores[name][k])
                                       for k in updateable
                                       if k in assetstores[name].keys())

                # tuples of (key,  value) for fields that can be updated
                # in the argument_hash for this assetstore type
                arg_hash_items = set((k, argument_hash[type][k])
                                     for k in updateable
                                     if k in argument_hash[type].keys())

                # if arg_hash_items not a subset of assetstore_items
                if not arg_hash_items <= assetstore_items:
                    # Update
                    ret = self.put('assetstore/%s' % id,
                                   parameters=argument_hash[type])

                    self.changed = True

            # And the asset store does not exist
            else:
                try:
                    # If __validate_[type]_assetstore exists then call the
                    # function with argument_hash. E.g.,  to check if the
                    # HDFS plugin is enabled
                    getattr(self, '__validate_%s_assetstore' % type
                            )(**argument_hash)
                except AttributeError:
                    pass

                ret = self.post('assetstore',
                                parameters=argument_hash[type])
                self.changed = True
        # If we want the assetstore to be gone
        elif self.module.params['state'] == 'absent':
            # And the assetstore exists
            if name in assetstores.keys():
                id = assetstores[name]['_id']
                ret = self.delete('assetstore/%s' % id,
                                  parameters=argument_hash[type])
                self.changed = True

        return ret

    def setting(self, key, value=None):
        ret = {}

        if value is None:
            value = ''

        if self.module.params['state'] == 'present':
            # Get existing setting value to determine self.changed
            existing_value = self.get('system/setting', parameters={'key': key})

            if existing_value is None:
                existing_value = ''

            params = {
                'key': key,
                'value': json.dumps(value)
            }

            try:
                response = self.put('system/setting', data=params)
            except requests.HTTPError as e:
                self.fail(e.response.json()['message'])

            if response:
                self.changed = unjsonify(existing_value) != unjsonify(value)

            if self.changed:
                ret['previous_value'] = existing_value
                ret['current_value'] = value
            else:
                ret['previous_value'] = ret['current_value'] = existing_value

        else:
            self.fail("%s state isn't supported by Girder setting module." %
                      self.module.params['state'])

        return ret


def main():
    """
    Entry point for ansible girder client module

    :returns: Nothing
    :rtype: NoneType
    """
    # Default spec for initalizing and authenticating
    argument_spec = {
        # __init__
        'host': dict(),
        'port': dict(),
        'apiRoot': dict(),
        'apiUrl': dict(),
        'scheme': dict(),
        'dryrun': dict(),
        'blacklist': dict(),

        # authenticate
        'username': dict(),
        'password': dict(
            no_log=True
        ),
        'token': dict(
            no_log=True
        ),
        'apiKey': dict(
            no_log=True
        ),

        # General
        'state': dict(default='present', choices=['present', 'absent'])
    }

    gcm = GirderClientModule()

    for method in gcm.required_one_of:
        argument_spec[method] = dict(type=gcm.spec[method]['type'])

    module = AnsibleModule(
        argument_spec=argument_spec,
        required_one_of=[gcm.required_one_of,
                         ['token', 'username', 'user', 'apiKey']],
        required_together=[['username', 'password']],
        mutually_exclusive=gcm.required_one_of,
        supports_check_mode=False)

    if not HAS_GIRDER_CLIENT:
        module.fail_json(msg='Could not import GirderClient!')

    try:
        gcm(module)

    except requests.HTTPError as e:
        import traceback
        module.fail_json(msg='%s:%s\n%s\n%s' % (e.__class__, str(e),
                                                e.response.text,
                                                traceback.format_exc()))
    except Exception as e:
        import traceback
        # exc_type, exc_obj, exec_tb = sys.exc_info()
        module.fail_json(msg='%s: %s\n\n%s' % (e.__class__, str(e),
                                               traceback.format_exc()))


if __name__ == '__main__':
    main()
