# Name:      lokai/lk_worker/nodes/search.py
# Purpose:   Search utilities for nodes
# Copyright: 2011: Database Associates Ltd.
#
#
#    Licensed under the Apache License, Version 2.0 (the "License");
#    you may not use this file except in compliance with the License.
#    You may obtain a copy of the License at
#
#    http://www.apache.org/licenses/LICENSE-2.0
#
#    Unless required by applicable law or agreed to in writing, software
#    distributed under the License is distributed on an "AS IS" BASIS,
#    WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
#
#    See the License for the specific language governing permissions and
#    limitations under the License.

#-----------------------------------------------------------------------

from types import StringTypes
from collections import deque

from sqlalchemy import and_, not_, or_, exists
from sqlalchemy.orm import aliased

from lokai.tool_box.tb_database.orm_interface import engine

from lokai.lk_worker.models import ndNode, ndEdge, ndParent

from lokai.lk_worker.nodes.graph import (top_trees,
                                         child_trees,
                                         )
from lokai.lk_worker.nodes.data_interface import get_node_from_reference

#-----------------------------------------------------------------------

def _make_candidates(*args):
    """ Join together a set of potential candidates to form a single
        candiate list.

        An empty list is significant. It means the list is empty
        (implying that a search on this list will return empty). A
        'None' value is taken as undefined.
    """
    defined = False
    op = []
    for possible in args:
        if possible is not None:
            defined = True
            if isinstance(possible, list):
                op.extend(possible)
            else:
                op.append(possible)
    if defined:
        return op
    return  None

#-----------------------------------------------------------------------

def search_down(source_selection, tops=None, depth_first=True):
    """Generate a search for nodes at or below each of the nodes
       listed in tops and matching some criterion.

       source_selection is an ORM query generated by
       engine.session.query()... or something similar.

       The result is a query that can be used with one(), all() and so
       on.

       If depth first is false, the query returns all nodes matching
       in the set of sub-trees.

       If depth first is true, the query returns the first matching
       node in each path through the sub-treees.
       
       Depth first search is achieved in two steps: one finds all the
       nodes matching the criterion and the other eliminates any nodes
       in the result that have an ancestor node also in the result.

       Warning:

       The search_down function does not work if there is no ndNode
       object in the query. The query is generated, and will provide a
       result, but the result will not be correct.
       
    """
    #
    # Add the tops[] filter ...
    base_query = source_selection
    candidates = _make_candidates(tops)
    if candidates is not None:
        base_query = base_query.outerjoin(
            (ndParent, ndParent.nde_idx == ndNode.nde_idx)).filter(
            or_(ndParent.nde_parent.in_(candidates),
                ndNode.nde_idx.in_(candidates)
                )
            ).distinct()
    if not depth_first:
        return base_query
        #>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>
    #
    # Find a version of the query that can be used in the depth_first filter

    #-------------------------------------------------------------------
    ndParentInner = aliased(ndParent)
    subx = base_query.from_self().outerjoin(
        (ndParentInner, ndParentInner.nde_parent ==
         ndNode.nde_idx)).add_entity(ndParentInner)
    subx = subx.statement.alias('xxx')
    
    base_query = base_query.filter(
        not_(exists(['*'],
                and_(ndNode.nde_idx == subx.c.nde_idx))))

    return base_query

#-----------------------------------------------------------------------

def search_up(source_selection, tops=None):
    """Generate a search for nodes at or above each of the nodes
       listed in tops and matching some criterion.

       The search returns all nodes matching the original query in all
       paths up to the terminating root nodes.

       All it has to do is to look in the ndParent entries for each
       entry in tops to find a complete set of backward paths.
    """
    base_query = source_selection
    candidates = _make_candidates(tops)
    if candidates is None: # not the same as empty list
        candidates = top_trees()
    #
    # Create a sub-select for parents
    ndParentInner = aliased(ndParent)
    ndNodeInner = aliased(ndNode)
    subx = engine.session.query(ndParentInner.nde_parent).filter(
        ndParentInner.nde_idx.in_(candidates)
        ).union(engine.session.query(ndNodeInner.nde_idx).filter(
        ndNodeInner.nde_idx.in_(candidates)))

    base_query = base_query.filter(
        and_(ndNode.nde_idx.in_(subx)))
    return base_query

#-----------------------------------------------------------------------
def search_here(source_selection, tops=None):
    """Apply the search to all the nodes in tops.

       Return a modified query.
       
       If tops is empty, apply the search to root nodes of the forest.
    """
    candidates = _make_candidates(tops)
    if candidates is None: # not the same as empty list
        candidates = top_trees()
    base_query = source_selection.filter(
        ndNode.nde_idx.in_(candidates))
    return base_query

#-----------------------------------------------------------------------

def search_children(source_selection, tops=None):
    """Apply the search to all the children of the nodes in tops.

       Return a modified query.
       
       If tops is empty, apply the search to root nodes of the forest.
    """
    candidates = _make_candidates(tops)
    if candidates is None: # not the same as empty list
        candidates = top_trees()
    base_query = source_selection.filter(
        and_(ndEdge.nde_parent.in_(candidates),
             ndNode.nde_idx == ndEdge.nde_child))
    return base_query

#-----------------------------------------------------------------------

def search_ordered(source_selection, flow, sort_column=None, sort_object=None):
    """ Apply an 'order by' using the sort object in the flow direction
    """
    query = source_selection
    if sort_column:
        if flow == 'ASC':
            query = query.order_by(
                getattr(sort_object, sort_column).asc())
        else:
            query = query.order_by(
                getattr(sort_object, sort_column).desc())
    if sort_object != ndNode or sort_column != 'nde_idx':
        # Add a default sub-ordering
        if flow == 'ASC':
            query = query.order_by(ndNode.nde_idx.asc())
        else:
            query = query.order_by(ndNode.nde_idx.desc())
    return query

#-----------------------------------------------------------------------

def _new_candidates(search_function, search_query, candidates, **kwargs):
    """
    Return a set of new candidates by applying the search_query to the
    given candidates using the search_function.
    """
    result_set = search_function(search_query, candidates, **kwargs).all()
    op = []
    for result in result_set:
        op.append(result.nde_idx)
    return op

#-----------------------------------------------------------------------

def _make_select(element):
    """
    Build a search query based on the given name or part name.
    """
    name_search = engine.session.query(ndNode)
    if '%' in element:
        name_search = name_search.filter(ndNode.nde_name.like(element))
    else:
        name_search = name_search.filter(ndNode.nde_name == element)
    return name_search

#-----------------------------------------------------------------------
    
def find_in_path(path, tops=None):
    """
    Search the given candidate trees for nodes that match the given
    path specification.
    
    The path specification is a psuedo path made up of one or more
    of three element types.
    
    A node is returned if the rightmost element matches the node
    name and the route to the node matches the preceding left part
    of the specification.
    
    Path elements are:
    
        '*' matches all the immediate children of each current candiate
            node.
    
        '**' matches zero or more levels of decendents. If this
             appears at the end of the path it matches the immediate
             children only.
    
        name matches the name of a node.

    If the path begins with '/' the match starts with the given set of
    candidates.

    If the path does not begin with '/' the search is done as though
    the path starts with /**/

    A trailing '/' is ignored.
    
    Examples;

       /foo searches the given candidates for one called foo

       foo searches the given candidates and all descendents to find
           the first match on each branch.
    
       foo/**/bar matches foo/bar, foo/a/bar, foo/a/b/bar and so on.
    
       foo/*/bar matches foo/a/bar but not foo/bar and not foo/a/b/bar

       foo/**/*/bar is the same as foo/*/bar

       foo/*/**/bar skips one level of descendents

       foo/* matches foo and returns all the children of foo

       foo/** matches foo and returns all the children of foo

    Path can be a list instead of a delimited string. An empty first
    element is equivalent to a rooted path.
    
    """
    if len(path) == 0:
        #
        # Nothing to search for
        return []
    candidates = _make_candidates(tops)
    if candidates is None: # not the same as empty list
        candidates = top_trees()

    if isinstance(path, (type(''), type(u''))):
        path = path.split('/')

    # If the path starts with a '/' then we search exactly as given.
    if path[0] == '':    
        path_head = path[0]
        path_tail = path[1:]
    else:
        # Path is not rooted, so we search all the way down
        path_head = '**'
        path_tail = path
        
    # If the path ends with a '/' we are confused because the meaning
    # is not obvious. So we just remove the last bit.
    if path_tail[-1] == '':
        del path_tail[-1]
    while candidates:
        #
        # Exit by a return statement when all elements have been processed
        if path_head == '**':
            if len(path_tail) == 0:
                return candidates
                #>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>
            path_head = path_tail[0]
            path_tail = path_tail[1:]
            if path_head not in ['*', '**']:
                candidates = _new_candidates(search_down,
                                            _make_select(path_head),
                                            candidates)
        elif path_head == '*':
            if len(path_tail) == 0:
                return candidates
                #>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>
            path_head = path_tail[0]
            path_tail = path_tail[1:]
            #
            # candidates currently holds nodes that match where we are
            # in the path. We have now isolated the next item in the
            # path, so we need to apply this next item to the children
            # of the children of the currently matching nodes.
            candidates = child_trees(candidates)
        #
        elif path_head == '':
            if len(path_tail) == 0:
                return candidates
                #>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>
            path_head = path_tail[0]
            path_tail = path_tail[1:]
        else:
            candidates = _new_candidates(search_here,
                                        _make_select(path_head),
                                        candidates)
            if not candidates:
                # Did not find explicit item - fail
                return candidates
                #>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>
            if len(path_tail) == 0:
                return candidates
                #>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>
            path_head = path_tail[0]
            path_tail = path_tail[1:]
            #
            # candidates currently holds nodes that match where we are
            # in the path. We have now isolated the next item in the
            # path, so we need to apply this next item to the children
            # of the currently matching nodes.
            candidates = child_trees(candidates)
            
    return candidates
    
#-----------------------------------------------------------------------

def find_nodes_by_client_references(refs):
    """ Takes a sequence of client references and returns a list of
        nodes
    """
    if not refs:
        return [] #>>>>>>>>>>>>>>>>>>>>
    if isinstance(refs, (type(''), type(u''))):
        refs = [refs]         
    return (engine.session.query(ndNode) 
            .filter(ndNode.nde_client_reference.in_(refs)).all())

#-----------------------------------------------------------------------

def find_from_string(given_string, tops=None, expand=None):
    """ Find a node from the given proto-identifier. This might be an
        idx, a client reference, a name or a path.

        Try each possibility in order of specificity.

        :given_string: A possible identifier.
        
            For wildcard name or path, the wildcards are SQL wild
            cards: % and _

            If the given string is not a direct identifier (nde_idx or
            client reference) it is assumed to be a path of node names
            and is passed to find_in_path.

        :tops: A list of nde_idx values defining the sub-trees to be
            searched. If ``tops`` is empty then all possible trees are
            searched.
        
        :expand: Optionally, expand=True, the path can be expanded to
            include '**' elements between all the parts of the
            path. The resulting path is then independent of the
            ancestral distance between the nodes found.

    """
    candidates = []
    if given_string:
        nde_idx = get_node_from_reference(given_string)
        if nde_idx is not None:
            candidates = [nde_idx]
        else:
            search_path = given_string
            if expand:
                path_elements = given_string.split('/')
                search_path = '/**/'.join(path_elements)
            candidates = find_in_path(search_path, tops)
    return candidates

#-----------------------------------------------------------------------

def search_nearest_up(given_idx, objective_function):
    """ Search breadth first _up_ the graph to find the nearest node
        that satisfies the objective function.

        The search returns zero, one or more than one matching
        node. If there is more then one node at equal distance from
        the starting point then all of these nodes are returned.

        The search also returns a count indicating the distance of the
        found items from the startng point.

        :given_idx: string - A node identifier for the start of the
            search.

            The test is applied to this node. The search could
            terminate immediately without going further through the
            graph.
            
        :objective_function: function - a test function that is called
            for each node in the search path. The search terminates
            when the function returns True.

            test = objective_function(node_object)

            The test criteria are not given. We assume that the
            objective function is specific for the envisaged test. A
            closure would be a good way of generating such.

       Return ([ ... ndNode ...], distance)
       
    """
    distance = 0
    work_list = set([given_idx])
    while work_list:
        found_list = []
        qy = engine.session.query(
            ndNode,
            ndEdge
            ).outerjoin(
            (ndEdge, (ndEdge.nde_child == ndNode.nde_idx))
            ).filter(
            ndNode.nde_idx.in_(work_list)
            )
        node_list = qy.all()
        found_list = set([x[0] for
                          x in node_list if objective_function(x[0])])
        if found_list:
            break
        work_list = set([x[1].nde_parent for
                         x in node_list if x[1] and x[1].nde_parent])
        distance += 1
    return (list(found_list), distance)

#-----------------------------------------------------------------------
