#!/usr/bin/env python
# -*- coding: utf-8 -*-
#
#  Create FHIR search params from NoSQL-like query structures.
#  2014, SMART Health IT.

import logging

from . import fhirreference

try:
    from urllib import quote_plus
except Exception as e:
    from urllib.parse import quote_plus

logger = logging.getLogger(__name__)


class FHIRSearch(object):
    """ Create a FHIR search from NoSQL-like query structures.
    """
    
    def __init__(self, resource_type, struct=None):
        self.resource_type = resource_type
        """ The resource type class. """
        
        self.params = []
        """ FHIRSearchParam instances. """
        
        self.wants_expand = False
        """ Used internally; whether or not `params` must be expanded first. """

        self.includes = []
        """ Used internally; stores list of included resources for the search. """
        
        if struct is not None:
            if dict != type(struct):
                raise Exception("Must pass a Python dictionary, but got a {}".format(type(struct)))
            self.wants_expand = True
            for key, val in struct.items():
                self.params.append(FHIRSearchParam(key, val))
    
    
    # MARK: Execution
    
    def construct(self):
        """ Constructs the URL with query string from the receiver's params.
        """
        if self.resource_type is None:
            raise Exception("Need resource_type set to construct a search query")
        
        parts = []
        if self.params is not None:
            for param in self.params:
                if self.wants_expand:
                    for expanded in param.handle():
                        parts.append(expanded.as_parameter())
                else:
                    parts.append(param.as_parameter())

        for reference_model, reference_field, reverse in self.includes:
            key = '_revinclude' if reverse else '_include'
            parameter = '{}={}:{}'.format(
                key, reference_model.resource_type, reference_field
            )
            parts.append(parameter)

        return '{}?{}'.format(self.resource_type.resource_type, '&'.join(parts))

    def include(self, reference_field, reference_model=None, reverse=False):
        """ Add a resource to be included in the search results. Includes will
        fetch additional resources referred to by the search results, or
        additional resources which themselves refer to the search results
        (reverse include). Recursive or iterative includes are not supported.
        Provides a fluent interface to allow method chaining.

        To include Patient resources when searching Observations:
            `s = FHIRSearch(Observation).include('subject')`
        To include Observation resources when searching Patients:
            `s = FHIRSearch(Patient).include('subject', Observation, reverse=True)`

        :param reference_field: The name of the search parameter (must be
                                FHIRReference type)
        :param reference_model: The type of the source resource from which the
                                join comes (only used for reverse includes)
        :param reverse: Whether this is a reverse include
        :returns: This FHIRSearch instance
        """

        if reference_model is None:
            reference_model = self.resource_type

        model_fields = {
            name: typ
            for name, _, typ, _, _, _
            in reference_model().elementProperties()
        }

        if model_fields.get(reference_field) is not fhirreference.FHIRReference:
            logging.warning(
                '%s does not have a reference type element named %s',
                reference_model.resource_type, reference_field
            )
            return self

        if reference_model is not self.resource_type and not reverse:
            logging.warning('Only reverse includes can have a different reference model')
            reverse = True

        self.includes.append((reference_model, reference_field, reverse))
        return self
    
    def perform(self, server):
        """ Construct the search URL and execute it against the given server.
        
        :param server: The server against which to perform the search
        :returns: A Bundle resource
        """
        if server is None:
            raise Exception("Need a server to perform search")
        
        from . import bundle
        res = server.request_json(self.construct())
        bundle = bundle.Bundle(res)
        bundle.origin_server = server
        return bundle
    
    def perform_resources(self, server):
        """ Performs the search by calling `perform`, then extracts all Bundle
        entries and returns a list of Resource instances.
        
        :param server: The server against which to perform the search
        :returns: A list of Resource instances
        """
        bundle = self.perform(server)
        resources = []
        if bundle is not None and bundle.entry is not None:
            for entry in bundle.entry:
                resources.append(entry.resource)
            
        return resources


class FHIRSearchParam(object):
    """ Holds one search parameter.
    
    The instance's `value` can either be a string value or a search construct
    dictionary. In the latter case the class's `handle` method must be called
    to arrive at search parameter instances that can be converted into a URL
    query.
    """
    
    def __init__(self, name, value):
        self.name = name
        self.value = value
    
    def copy(self):
        clone = object.__new__(self.__class__)
        clone.__dict__ = self.__dict__.copy()
        return clone
    
    def handle(self):
        """ Parses the receiver's value and returns a list of FHIRSearchParam
        instances. Needs only be called if the param needs to be handled, i.e.
        its value is a query structure.
        
        :returns: A list with one or more FHIRSearchParam instances, not
        altering the receiver
        """
        handler = FHIRSearchParamHandler.handler_for(self.name)(None, self.value)
        return handler.handle(self.copy())
    
    def as_parameter(self):
        """ Return a string that represents the reciever as "key=value".
        """
        return '{}={}'.format(self.name, quote_plus(self.value, safe=',<=>'))


class FHIRSearchParamHandler(object):
    handles = None
    handlers = []
    
    @classmethod
    def announce_handler(cls, handler):
        cls.handlers.append(handler)
    
    @classmethod
    def handler_for(cls, key):
        for handler in cls.handlers:
            if handler.can_handle(key):
                return handler
        return cls
    
    @classmethod
    def can_handle(cls, key):
        if cls.handles is not None:
            return key in cls.handles
        return True         # base class handles everything else, so be sure to test it last!
    
    
    def __init__(self, key, value):
        self.key = key
        self.value = value
        self.modifier = []
        self.multiplier = []
    
    def handle(self, param):
        """ Applies all handlers to the given search parameter.
        :returns: A list of one or more new `FHIRSearchParam` instances
        """
        self.prepare()
        return self.expand(param)
    
    def prepare(self, parent=None):
        """ Creates sub-handlers as needed, then prepares the receiver.
        """
        if dict == type(self.value):
            for key, val in self.value.items():
                handler = FHIRSearchParamHandler.handler_for(key)(key, val)
                handler.prepare(self)
        
        if parent is not None:
            parent.multiplier.append(self)
    
    def expand(self, param):
        """ Executes the receiver's modifier and multiplier on itself, applying
        changes to the given search param instance.
        
        :returns: A list of one or more FHIRSearchParam instances
        """
        for handler in self.modifier:
            handler.expand(param)
        
        self.apply(param)
        
        # if we have multiplier, expand sequentially
        if len(self.multiplier) > 0:
            expanded = []
            for handler in self.multiplier:
                clone = param.copy()
                expanded.extend(handler.expand(clone))
            
            return expanded
        
        # no multiplier, just return the passed-in paramater
        return [param]
    
    def apply(self, param):
        if self.key is not None:
            param.name = '{}.{}'.format(param.name, self.key)
        if 0 == len(self.multiplier):
            param.value = self.value


class FHIRSearchParamModifierHandler(FHIRSearchParamHandler):
    modifiers = {
        '$asc': ':asc',
        '$desc': ':desc',
        '$exact': ':exact',
        '$missing': ':missing',
        '$null': ':missing',
        '$text': ':text',
    }
    handles = modifiers.keys()
    
    def apply(self, param):
        if self.key not in self.__class__.modifiers:
            raise Exception('Unknown modifier "{}" for "{}"'.format(self.key, param.name))
        param.name += self.__class__.modifiers[self.key]
        param.value = self.value


class FHIRSearchParamOperatorHandler(FHIRSearchParamHandler):
    operators = {
        '$gt': '>',
        '$lt': '<',
        '$lte': '<=',
        '$gte': '>=',
    }
    handles = operators.keys()
    
    def apply(self, param):
        if self.key not in self.__class__.operators:
            raise Exception('Unknown operator "{}" for "{}"'.format(self.key, param.name))
        param.value = self.__class__.operators[self.key] + self.value


class FHIRSearchParamMultiHandler(FHIRSearchParamHandler):
    handles = ['$and', '$or']
    
    def prepare(self, parent):
        if list != type(self.value):
            raise Exception('Expecting a list argument for "{}" but got {}'.format(parent.key, self.value))
        
        handlers = []
        for val in self.value:
            if dict == type(val):
                for kkey, vval in val.items():
                    handlers.append(FHIRSearchParamHandler.handler_for(kkey)(kkey, vval))
            else:
                handlers.append(FHIRSearchParamHandler.handler_for(parent.key)(None, val))
        
        if '$and' == self.key:
            for handler in handlers:
                handler.prepare(parent)
        elif '$or' == self.key:
            ors = [h.value for h in handlers]
            handler = FHIRSearchParamHandler.handler_for(parent.key)(None, ','.join(ors))
            handler.prepare(parent)
        else:
            raise Exception('I cannot handle "{}"'.format(self.key))


class FHIRSearchParamTypeHandler(FHIRSearchParamHandler):
    handles = ['$type']
    
    def prepare(self, parent):
        parent.modifier.append(self)
    
    def apply(self, param):
        param.name = '{}:{}'.format(param.name, self.value)
    

# announce all handlers
FHIRSearchParamHandler.announce_handler(FHIRSearchParamModifierHandler)
FHIRSearchParamHandler.announce_handler(FHIRSearchParamOperatorHandler)
FHIRSearchParamHandler.announce_handler(FHIRSearchParamMultiHandler)
FHIRSearchParamHandler.announce_handler(FHIRSearchParamTypeHandler)

