#!/usr/bin/env python
# -*- coding: utf-8 -*-
# Copyright 2008 Martin Manns
# Distributed under the terms of the GNU General Public License
# generated by wxGlade 0.6 on Mon Mar 17 23:22:49 2008

# --------------------------------------------------------------------
# pyspread is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# pyspread is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with pyspread.  If not, see <http://www.gnu.org/licenses/>.
# --------------------------------------------------------------------

"""
_datastructures
===============

Provides
  1. Grid object class Grid
  2. Macro user dictionary class Macros
  3. Undo/Redo framework class UnRedo

"""

#import inspect
import types
import UserDict

import numpy
import networkx as nx

from pyspread._arrayhelper import getflatpos, getshapedpos
from pyspread._interfaces import string_match,  Digest

X, Y, Z, S = [None] * 4

class PyspreadGrid(object):
    """Central data object that provides two numpy based 3D object arrays.

    One array (sgrid) stores strings that contain python expressions. The 
    second array (fgrid) stores functions that are derived from these strings.
    This class makes use of eval and does not provide any security features.
    It furthermore stores functions as globals if a function name is given in 
    the expression (e.g. 'a = 2' --> a = lambda x=0: 2; global a).

    Parameters
    ----------
    parent: object
    \tParent class
    dimensions : 3 tuple of int
    \tThe dimensions of sgrid and fgrid (defaults to (10, 10, 10))

    """
        
    def __init__(self, dimensions=(10, 10, 10)):
        global S
        S = self
        self.name = "S"
        
        self.nodegraph = nx.DiGraph() # for cycle detection
        
        self.fgrid = numpy.zeros(dimensions, dtype="O")
        sgrid = numpy.zeros(dimensions, dtype="S")
        self.sgrid = numpy.array(sgrid, dtype="O")
        del sgrid
        
        self.__shape = self.fgrid.shape
        
        self.macros = Macros({}) # Macros from Macrolist
        
        self.unredo = UnRedo()
        
        self.resultcache = {}
        
        self._unicode_digest = Digest(acceptable_types=[types.UnicodeType])
        self._slice_digest = Digest(acceptable_types=[types.SliceType])
    
    def __getshape(self):
        return self.fgrid.shape
    shape = property(__getshape)
    
    def __getitem__(self, key):
        """This function may be used for internal calls from fgrid!"""
        
        # Store result dimensionality information
        resdim = tuple(type(dimkey) is not types.SliceType for dimkey in key)
        
        def _zero_openslice(obj):
            """Returns 0 if True or open slice if False"""
            if obj:
                return 0
            else:
                return slice(None, None, None)
        
        resdim = [_zero_openslice(resdimkey) for resdimkey in resdim]
        
        # Convert the key elements to slices
        key = [self._slice_digest(dimkey) for dimkey in key]
        
        # Is the key already in the resultcache so we can avoid re-calculation
        if repr(key) in self.resultcache:
            return self.resultcache[repr(key)]
        
        # Is a cycle in the nodegraph?
        if not nx.is_directed_acyclic_graph(self.nodegraph):
            return "Recursion detected!"
        
        # Calculate cell contents for key
        global X, Y, Z
        _key_storage = X, Y, Z
        X, Y, Z = key
        
        fgrid_key = self.fgrid[key]
        
        # Replace 0 functions by functions that return 0
        for kx0, ky0, kz0 in zip(*numpy.nonzero(fgrid_key == 0)):
            fgrid_key[kx0, ky0, kz0] = lambda x = 0: None
        
        res = self._call_fgrid_functions(fgrid_key, fromkey = _key_storage)
        
        # Reduce result dimensionality only if we are not on cell level
        if _key_storage != (None, None, None):
            try: 
                res = res[resdim]
            except (TypeError, IndexError):
                pass
        
        # Restore global co-ordinates
        X, Y, Z = _key_storage
        
        # Add result to cache
        if repr(key) not in self.resultcache:
            self.resultcache[repr(key)] = res
        
        return res
        
    def get_item_from_maingrid(self, key):
        """
        Gets items with empty node graph. 
        
        This method empties nodegraph and calls __getitem__
        It may be used *only* for external calls from main grid!
        
        """
        
        # We are called externally and therefore have not visited any cell, yet
        self.nodegraph = nx.DiGraph()
        
        return self.__getitem__(key)
        
        
    def __setitem__(self, pos, value):
        self.resultcache = {}
        undo_operation = (self.__setitem__, [pos, self.sgrid.__getitem__(pos)])
        redo_operation = (self.__setitem__, [pos, value])
        self.unredo.append(undo_operation, redo_operation)
        self.sgrid.__setitem__(pos, value)
        return value
    
    def __len__(self):
        return len(self.sgrid)
    
    
    def isinsclice(self, slc, dim, index):
        """
        Determines if an index is in a slice of dimension dim
        
        Parameters
        ----------
        slc: slice
        \tThe slice for which the test is done
        
        dim: int
        \tThe dimnsion of the grid
        
        index: int
        \tThe index to be tested
        
        """
        
        length = self.fgrid.shape[dim]
        
        if slc.step is None:
            slc = slice(slc.start, slc.stop, 1)
        
        if slc.step == 0:
            raise ValueError, "slice step cannot be zero"
        
        if slc.step > 0:
            if slc.start is None:
                slc = slice(0, slc.stop, slc.step)
            if slc.stop is None:
                slc = slice(slc.start, length, slc.step)
        elif slc.step < 0:
            if slc.start is None:
                slc = slice(length -1, slc.stop, slc.step)
            if slc.stop is None:
                slc = slice(slc.start, 0, slc.step)
        
        if index < slc.start or index >= slc.stop:
            return False
        
        return (index - slc.start) % slc.step == 0
    
    
    def key_in_slicetuple(self, pos, slc_tuple):
        """Tests if a position is in a 3-tuple of slices or ints"""
        
        inranges = [] # List of bool to store results for each dimension
        
        for i, slc in enumerate(slc_tuple):
            if type(slc) is types.SliceType:
                inranges.append(self.isinsclice(slc, i, pos[i]))
            else:
                inranges.append(slc == pos[i])
        
        return min(inranges)
    
    def _nodegraph_append(self, fromnode):
        """Appends key slices to nodegraph"""
        
        def _nonone(obj, fallback):
            """Returns fallback if object is None"""
            if obj is None:
                return fallback
            else:
                return obj
        
        # Make sure that fromnode is a single node
        from_xyz = [[], [], []]
        for i, nodeele in enumerate(fromnode):
            if type(nodeele) is types.SliceType:
                from_xyz[i] = xrange(*nodeele.indices(2**31-1))
            else:
                from_xyz[i] = [nodeele]
        
        r_x = xrange(_nonone(X.start, 0), \
                    _nonone(X.stop, self.fgrid.shape[0]), \
                    _nonone(X.step, 1))
        r_y = xrange(_nonone(Y.start, 0), \
                    _nonone(Y.stop, self.fgrid.shape[1]), \
                    _nonone(Y.step, 1))
        r_z = xrange(_nonone(Z.start, 0), \
                    _nonone(Z.stop, self.fgrid.shape[2]), \
                    _nonone(Z.step, 1))
        
        newnodes = [(x, y, z) for x in r_x for y in r_y for z in r_z]
        fromnodes = [(x, y, z) for x in from_xyz[0] 
                       for y in from_xyz[1] for z in from_xyz[2]]
        newedges = [(fn, newnode) for newnode in newnodes for fn in fromnodes]
        #print newedges
        
        self.nodegraph.add_nodes_from(newnodes)
        self.nodegraph.add_edges_from(newedges)
    
    
    def _call_fgrid_functions(self, fgrid_pos, fromkey):
        """
        Calls functions in the numpy.ndarray fgrid_pos and returns result
        
        Parameters:
        -----------
        fgrid_pos: 3-tuple of slices
        \tFunction array that is called
        fromkey: 3-tuple
        \tIndex of calling cell
        
        """
        
        global X, Y, Z
        _pos_orig = X, Y, Z
        
        self._nodegraph_append(fromkey)
        
        def _call(func, pos):
            """Calls the function func with the parameter pos"""
            
            try:
                return func(pos)
            except Exception, error_message:
                return error_message
        
        try: 
            func_it = fgrid_pos.flatten("F")
        except:
            raise AssertionError, unicode(type(fgrid_pos)) + \
                                  " is not numpy.ndarray"
        
        result = numpy.zeros(fgrid_pos.shape, dtype="O")
        
        for i, func in enumerate(func_it):
            shapedpos = getshapedpos(fgrid_pos, i)
            try:
                X = _pos_orig[0].start + shapedpos[0]
                Y = _pos_orig[1].start + shapedpos[1]
                Z = _pos_orig[2].start + shapedpos[2]
            except:
                pass
            
            result[shapedpos] = _call(func, pos=i)
        
        if result.shape[2] == 1:
            result = result[:, :, 0]
        
        if result.shape[1] == 1:
            try:
                result = result[:, 0, :]
            except IndexError:
                result = result[:, 0]
        
        if result.shape[0] == 1:
            reslen = len(result.shape)
            if reslen == 1:
                result = result[0]
            elif reslen == 2:
                result = result[0, :]
            else:
                result = result[0, :, :]
            
        return result
    
    def insert(self, insertionpoint, notoinsert, newcells=None, nocells=False):
        """Insert rows, columns or tables
        
        Parameters:
        -----------
        insertionpoint: 3-tuple or list with 3 elements
        \t3 tuple elements are None except for one element.
        \tThis element corresponds to the dimension of the insertion operation.
        \tand describes the position of the insertion operation.
        notoinsert: int
        \tNo. cols/rows/tables that are to be inserted
        nocells: bool, defaults to False
        \tNo new cells are inserted even though no new cells are provided
        
        """
        
        # The function does only work correctly with correct insertionpoint
        assert len(insertionpoint) == 3
        assert list(insertionpoint).count(None) == 2
        
        self.resultcache = {}
        
        undo_operation = (self.remove, [insertionpoint, notoinsert])
        redo_operation = (self.insert, [insertionpoint, notoinsert, newcells])
        self.unredo.append(undo_operation, redo_operation)
        
        ins_points = list(insertionpoint)
        axis = ins_points.index(max(ins_points))
        if newcells is None and not nocells:
            newshapeindices = list(self.sgrid.shape)
            newshapeindices[axis] = notoinsert
            newcells = numpy.zeros(newshapeindices, dtype="O")
        
        def _subarrays(array, newcells, key):
            """Returns tuple of sub-arrays that can be concatenated"""
            if nocells:
                subarrays = (array[:key[0], :key[1], :key[2]], \
                             array[key[0]:, key[1]:, key[2]:])
            else:
                subarrays = (array[:key[0], :key[1], :key[2]], \
                             newcells, \
                             array[key[0]:, key[1]:, key[2]:])
            return filter(lambda x:list(x.flat) != [], subarrays)
            
        subarrays = _subarrays(self.sgrid, newcells, ins_points)
        print [s.shape for s in subarrays]
        
        self.sgrid = numpy.concatenate(subarrays, axis=axis)
        subarrays = _subarrays(self.fgrid, newcells, ins_points)
        self.fgrid = numpy.concatenate(subarrays, axis=axis)
        
    def remove(self, removalpoint, notoremove):
        """Remove rows, columns or tables
                
        Parameters:
        -----------
        removalpoint: 3-tuple or list with 3 elements
        \removalpoint must be a 3 tuple, which is None except for one element.
        \tThis element corresponds to the dimension of the removal operation
        \tand describes the position of the removal operation.
        notoremove: int
        \tNo. cols/rows/tables that are to be removed
        
        """
        
        self.resultcache = {}
        rmp = removalpoint
        axis = rmp.index(max(rmp))
        removedcellslice = [slice(rpele, rpele, 1) for rpele in rmp]
        removedcellslice[axis] = slice(removedcellslice[axis].start, \
                                 removedcellslice[axis].stop + notoremove, 1)
        removedcells = self.sgrid[removedcellslice]
        
        undo_operation = (self.insert, [removalpoint, notoremove, removedcells])
        redo_operation = (self.remove, [removalpoint, notoremove])
        self.unredo.append(undo_operation, redo_operation)
        
        self.sgrid = numpy.delete(self.sgrid, removedcellslice[axis], axis)
        self.fgrid = numpy.delete(self.fgrid, removedcellslice[axis], axis)
    
    def _spread_0dim(self, value, pos, shape):
        """Spread single values"""
        
        posx, posy, posz = pos
        
        S[posx, posy, posz] = self._unicode_digest(value)
        
        return 1
    
    def _spread_1dim(self, value, pos, shape):
        """Spread 1D values"""
        
        posx, posy, posz = pos
        
        rowsleft = self.sgrid[posx:, :, :].shape[0] - shape[0]
        
        if rowsleft < 0:
            value = value[:shape[0] + rowsleft]
        
        strings = self._get_value_strings(value)
        
        S[posx: posx + shape[0], posy, posz] = strings
        
        return len(strings)
    
    def _spread_2dim(self, value, pos, shape):
        """Spread 2D values"""
        
        posx, posy, posz = pos
        
        rowsleft = self.sgrid[posx:, :, :].shape[0] - shape[0]
        colsleft = self.sgrid[:, posy:, :].shape[1] - shape[1]
        
        if rowsleft < 0:
            value = value[:shape[0] + rowsleft, :]
        if colsleft < 0:
            value = value[:, :shape[1] + colsleft]
        
        strings = self._get_value_strings(value)
        
        S[posx: posx + shape[0], \
          posy: posy + shape[1], \
          posz] = strings
        
        return len(strings)
        
    def _spread_3dim(self, value, pos, shape):
        """Spread 3D values"""
        
        posx, posy, posz = pos
        
        rowsleft = self.sgrid[posx:, :, :].shape[0] - shape[0]
        colsleft = self.sgrid[:, posy:, :].shape[1] - shape[1]
        tablesleft = self.sgrid[:, :, posz:].shape[2] - shape[2]
        
        if rowsleft < 0:
            value = value[:shape[0] + rowsleft, :, :]
        if colsleft < 0:
            value = value[:, :shape[1] + colsleft, :]
        if tablesleft < 0:
            value = value[:, :, :shape[2] + tablesleft]
        
        strings = self._get_value_strings(value)
        
        S[posx: posx + shape[0], \
          posy: posy + shape[1], \
          posz: posz + shape[2]] = strings
    
    def _get_value_strings(self, value):
        """Returns numpy array of string representations of value elements"""
        
        flat_res = numpy.array(map(self._unicode_digest, value.flat))
        return flat_res.reshape(value.shape)
    
    def spread(self, value, pos):
        """Spread values into the grid with the top-left-upmost position pos
        
        Only rectangular matrices can be spread at this time
        Returns number of changed cells.
        
        Parameters
        ----------
        value: Scalar, iterable or array
        \tValues that shall be spread
        pos: 3-tuple of int
        \tValid index of self for top-left value insertion
        
        """
        
        valdim = numpy.ndim(value)
        
        if valdim < 0:
            raise ValueError, "Dimension of " + unicode(value) + " negative"
        
        if valdim > 3:
            raise ValueError, "Dimension of " + unicode(value) + " too high"
        
        value = numpy.array(value)
        
        shape = value.shape
        
        spreadfuncs = [self._spread_0dim, \
                       self._spread_1dim, 
                       self._spread_2dim, 
                       self._spread_3dim]
        
        result = spreadfuncs[valdim](value, pos, shape)
        
        self.eval_function_grid()
        
        return result

    def findnextmatch(self, startpos, find_string, flags):
        """ Returns a tuple with the position of the next match of find_string
        
        Returns None if string not found.
        
        Parameters:
        -----------
        startpos:   Start position of search
        find_string:String to be searched for
        flags:      List of strings, out ouf 
                    ["UP" xor "DOWN", "WHOLE_WORD", "MATCH_CASE"]
        
        """
        
        # Index of current cell in flattened grid
        flatgridpos = getflatpos(self.sgrid, startpos) 
        # Search in each cell until match
        flatgrid = self.sgrid.flatten('F') # We need Fortran order here
        # Start at flatgridpos
        searchgrid = numpy.concatenate((flatgrid[flatgridpos:], \
                                        flatgrid[:flatgridpos]))
        if "UP" in flags:
            searchgrid = searchgrid[::-1]
        findpos = None
        for key, datastring in enumerate(searchgrid):
            if datastring != '' and \
               string_match(datastring, find_string, flags) is not None:
                if "DOWN" in flags:
                    correctedpos =  flatgridpos + key
                elif "UP" in flags:
                    correctedpos =  flatgridpos - key - 1
                else:
                    raise AttributeError, "UP xor DOWN flag " + \
                                          "not present in flags"
                findpos = getshapedpos(self.sgrid, correctedpos)
                break
        return findpos

    def get_function_cell_indices(self, gridslice = None):
        """
        Get indices for all fgrid cells != 0
        
        Parameters:
        -----------
        gridslice: 3-tuple of slices
        \tThe slice of the grid that is searched. Defaults to whole grid.
        
        """
        if gridslice is None:
            gridslice = tuple([slice(None)]*len(self.sgrid.shape))
        sgrid = self.sgrid[gridslice]
        fgrid = self.fgrid[gridslice]
        nonzero = list(set(zip(*numpy.nonzero(sgrid)) + \
                      zip(*numpy.nonzero(fgrid))))
        gridslice = list(gridslice)
        for i in xrange(len(self.sgrid.shape)):
            if gridslice[i].start is None:
                gridslice[i] = slice(0, gridslice[i].stop, gridslice[i].step)
        nonzero = [tuple(cell[i] + gridslice[i].start \
                        for i in xrange(len(self.sgrid.shape))) \
                            for cell in nonzero]
        return nonzero
    
    def _assign_globally(self, assignvar, expression):
        """Assigns the result of expression to the assignvar"""
        
        if assignvar in ['S', 'X', 'Y', 'Z']:
            raise SyntaxError, "Cell name must not mask " \
                               "the built-in globals S, X, Y, Z"
        
        exec("".join(["global ", assignvar])) in globals()
        exec("=".join([assignvar, expression])) in globals()
        
    def eval_function_grid(self, gridslice = None):
        """ 
        Eval the function grid from the string grid 
        
        Parameters:
        -----------
        gridslice: 3-tuple of slices
        \tThe slice of the grid that is searched. Defaults to whole grid.\
        
        """
        if gridslice is None:
            gridslice = tuple([slice(None, None)] * len(self.sgrid.shape))
        sgrid = self.sgrid[gridslice]
        fgrid = self.fgrid[gridslice]
        string_elements = set(zip(*numpy.nonzero(sgrid)) + \
                              zip(*numpy.nonzero(fgrid)))
        global S
        S = self
        
        string_digest = Digest(acceptable_types=[types.UnicodeType], \
                               fallback_type=types.StringType)
        
        for i in string_elements:
            errormessage = None
            
            if sgrid[i] == "":
                fgrid[i] = 0
            elif sgrid[i] == 0:
                fgrid[i] = 0
            else:
                expression = "".join(["lambda pos:", string_digest(sgrid[i])])
                try:
                    fgrid[i] = eval(expression, globals())
                except Exception, errormessage:
                    pass
            
            if errormessage is not None and '=' in sgrid[i]:
                # We got an errormessageor that may be caused by 
                # a global assignment ("a=2")
                try:
                    assignvar, expression = sgrid[i].split('=', 1)
                    
                    self._assign_globally(assignvar, expression)
                    
                    evalstring = "".join(["lambda pos:", expression])
                    fgrid[i] = eval(evalstring, globals())
                    
                    errormessage = None
                except Exception, errormessage:
                    pass
            if errormessage is not None:
                fgrid[i] = lambda x: errormessage

    def set_global_macros(self, macros=None):
        """ Sets macros to global scope """
        
        self.resultcache = {}
        if macros is None: 
            macros = self.macros
        for macroname, macro in macros.iteritems():
            globals()[macroname] = macro

# end of class PyspreadGrid


class Macros(UserDict.IterableUserDict):
    """User dict class for macros.

    This class provides a getter and setter method for storing the full
    macro Python code in the 'macrocode' attribute of the funcdict.

    """
    def get_macro(self, code):
        """ Returns the function derived from the code string code. """
        funcname = code.split("(")[0][3:].strip()
        # Windows exec does not like Windows newline
        code = code.replace('\r\n', '\n')
        exec(code)
        func = eval(funcname, globals(), locals())
        func.func_dict['macrocode'] = code
        return func
        
    def add(self, code):
        """ Adds a macro with the code string 'code' to the macro dict"""
        func = self.get_macro(code)
        if func.__name__ in self:
            return 0
        self[func.__name__] = func
        return func
        
# end of class Macros

class UnRedo(object):
    """Undo/Redo framework class.
    
    For each undoable operation, the undo/redo framework stores the
    undo operation and the redo operation. For each step, a 4-tuple of:
    1) the function object that has to be called for the undo operation
    2) the undo function paarmeters as a list
    3) the function object that has to be called for the redo operation
    4) the redo function paarmeters as a list
    is stored.
    
    One undo step in the application can comprise of multiple operations.
    Undo steps are separated by the string "MARK".
    
    The attributes should only be written to by the class methods.

    Attributes
    ----------
    undolist: List
    \t
    redolist: List
    \t
    active: Boolean
    \tTrue while an undo or a redo step is executed.

    """
    
    def __init__(self):
        # [(undofunc, [undoparams, ...], redofunc, [redoparams, ...]), 
        # ..., "MARK", ...]
        # "MARK" separartes undo/redo steps
        self.undolist = []
        self.redolist = []
        self.active = False
        
    def mark(self):
        """Inserts a mark in undolist and empties redolist"""
        if self.undolist != [] and self.undolist[-1] != "MARK":
            self.undolist.append("MARK")
        self.redolist = []
    
    def undo(self):
        """Undos operations until next mark and stores them in the redolist"""
        self.active = True
        while self.undolist != [] and self.undolist[-1] == "MARK":
            self.undolist.pop()
        if self.redolist != [] and self.redolist[-1] != "MARK":
            self.redolist.append("MARK")
        while self.undolist != []:
            step = self.undolist.pop()
            if step == "MARK": 
                break
            self.redolist.append(step)
            step[0](*step[1])
        self.active = False
        
    def redo(self):
        """Redos operations until next mark and stores them in the undolist"""
        self.active = True
        while self.redolist != [] and self.redolist[-1] == "MARK":
            self.redolist.pop()
        if self.undolist != [] and self.undolist[-1] != "MARK":
            self.undolist.append("MARK")        
        while self.redolist != []:
            step = self.redolist.pop()
            if step == "MARK": 
                break
            self.undolist.append(step)
            step[2](*step[3])
        self.active = False

    def reset(self):
        """Empties both undolist and redolist"""
        self.__init__()

    def append(self, undo_operation, operation):
        """Stores an operation and its undo operation in the undolist"""
        if not self.active:
            self.undolist.append(undo_operation + operation)

# end of class UnRedo
