#!/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 types
import UserDict
from itertools import izip

import numpy

from config import DEBUG
from _interfaces import string_match

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.isrecursion = 0
        self.name = "S"
        self.fgrid = numpy.zeros(dimensions, dtype="O")
        self.sgrid = numpy.zeros(dimensions, dtype="S")
        self.sgrid = numpy.array(self.sgrid, dtype="O")
        self.macros = Macros({}) # Macros from Macrolist
        self.unredo = UnRedo()
        self.resultcache = {}

    def __getitem__(self, pos):
        if repr(pos) in self.resultcache:
            return self.resultcache[repr(pos)]
        global X, Y, Z
        _pos_storage = X, Y, Z
        X, Y, Z = pos
        self.isrecursion += 1
        try:
            fgrid_pos = self.fgrid[pos]
        except IndexError, error_message: 
            self.isrecursion -= 1
            return error_message
        if type(fgrid_pos) is types.FunctionType:
            fgrid_pos = numpy.array([fgrid_pos], dtype="O")
        if fgrid_pos is 0:
            self.isrecursion -= 1
            return ''
        
        # We are now sure that fgrid_pos is a kind of list here
        if type(fgrid_pos) in [types.TupleType, types.ListType]:
            fgrid_pos = numpy.array(fgrid_pos, dtype = "O")
        elif type(fgrid_pos) is not numpy.ndarray:
            raise AssertionError, "fgrid_pos has wrong type: " + \
                                  str(type(fgrid_pos))
        # We are now sure that fgrid_pos is a numpy.ndarray
        #print fgrid_pos
        res = self.__call_fgrid_function(fgrid_pos)
        X, Y, Z = _pos_storage
        self.isrecursion -= 1
        if repr(pos) not in self.resultcache:
            self.resultcache[repr(pos)] = res
        return res
    
    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 __call_fgrid_function(self, fgrid_pos):
        """
        Calls functions in the numpy.ndarray fgrid_pos and returns result
        
        Parameters:
        -----------
        fgrid_pos: Function array that is called
        
        """
        
        global X, Y, Z
        _pos_orig = X, Y, Z
        
        def _call(func, pos):
            if func is 0:
                return 0
            else:
                try:
                    return func(pos)
                except Exception, error_message:
                    return error_message
        
        try: 
            func_it = fgrid_pos.flat
        except:
            raise AssertionError, str(type(fgrid_pos))+" is not numpy.ndarray"
        resultlist = []
        for i, f in enumerate(func_it):
            #print self.getshapedpos(fgrid_pos, i)
            shapedpos = self.getshapedpos(fgrid_pos, i)
            #print _pos_orig, shapedpos
            try:
                X = _pos_orig[0].start + shapedpos[0]
                Y = _pos_orig[1].start + shapedpos[1]
                Z = _pos_orig[2].start + shapedpos[2]
            except:
                pass
            resultlist.append(_call(f, pos=i))
        result = numpy.array(resultlist)
        try:
            len(result)
        except TypeError:
            return result
        if len(result) == 1:
            return result[0]
        else:
            return result.reshape(fgrid_pos.shape)

    def insert(self, insertionpoint, notoinsert, newcells=None):
        """Insert rows, columns or tables
        
        Parameters:
        -----------
        insertionpoint: 3-tuple or list with 3 elements
        \tinsertionpoint must be a 3 tuple, which is 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
        
        """
        
        # The function does only work correctly with correct insertionpoint
        assert len(insertionpoint) == 3
        assert list(insertionpoint).count(None) == 2
        
        undo_operation = (self.remove, [insertionpoint, notoinsert])
        redo_operation = (self.insert, [insertionpoint, notoinsert, newcells])
        self.unredo.append(undo_operation, redo_operation)
        
        ip = list(insertionpoint)
        axis = ip.index(max(ip))
        if newcells is None:
            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"""
            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, ip)
        self.sgrid = numpy.concatenate(subarrays, axis=axis)
        subarrays = _subarrays(self.fgrid, newcells, ip)
        self.fgrid = numpy.concatenate(subarrays, axis=axis)

    def getflatpos(self, array, pos):
        """Get index of a cell in a flattened numpy array in Fortran order."""
        from operator import mul
        cumgsr = [1] + \
            [reduce(mul, array.shape[:i]) for i in xrange(1, len(array.shape))]
        return sum(p * e for p, e in izip(pos, cumgsr))
        
    def getshapedpos(self, array, pos):
        """Get cell index from a flattened array index in Fortran order."""
        shape = array.shape
        def _rek(i, pos):
            try: currpos = pos % shape[i]
            except IndexError: return []
            return [currpos] + _rek(i+1, pos // shape[i])
        return tuple(_rek(0, pos))

    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
        
        """
        
        rp = removalpoint
        axis = rp.index(max(rp))
        removedcellslice = [slice(rpele, rpele) for rpele in rp]
        removedcellslice[axis] = slice(removedcellslice[axis].start, \
                                       removedcellslice[axis].stop + notoremove)
        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(self, value, pos):
        """Spread values into the grid with the top-left-upmost position x, y, z
        
        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
        
        """
        
        x, y, z = pos
        valdim = numpy.ndim(value)
        if valdim in xrange(1, 4):
            value = numpy.array(value)
            shape = value.shape
            
            if valdim == 1:
                rowsleft = self.sgrid[x:, :, :].shape[0] - shape[0]
                if rowsleft < 0:
                    value = value[:shape[0] + rowsleft]
            if valdim == 2:
                rowsleft = self.sgrid[x:, :, :].shape[0] - shape[0]
                colsleft = self.sgrid[:, y:, :].shape[1] - shape[1]
                if rowsleft < 0:
                    value = value[:shape[0] + rowsleft, :]
                if colsleft < 0:
                    value = value[:, :shape[1] + colsleft]
            if valdim > 2:
                rowsleft = self.sgrid[x:, :, :].shape[0] - shape[0]
                colsleft = self.sgrid[:, y:, :].shape[1] - shape[1]
                tablesleft = self.sgrid[:, :, z:].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 = numpy.array(map(repr, value.flat)).reshape(value.shape)
            
            if valdim == 1:
                S[x: x + shape[0], y, z] = strings
            elif valdim == 2:
                S[x: x + shape[0], y: y + shape[1], z] = strings
            elif valdim == 3:
                S[x: x + shape[0], y: y + shape[1], z: z + shape[2]] = strings
        elif valdim == 0:
            #res = self.__setitem__(slice(target, target, None), repr(value))
            S[x, y, z] = repr(value)
        else:
            raise ValueError, "Dimension of " + str(value) + " too high"
        self.eval_function_grid()

    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"]
        
        """
        #print repr(find_string)
        # Index of current cell in flattened grid
        flatgridpos = self.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 = self.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 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
        for i in string_elements:
            err = None 
            if sgrid[i] == "":
                fgrid[i] = 0
            else:
                try:
                    iter(sgrid[i])
                except TypeError:
                    raise TypeError, "sgrid may only hold iterable strings." + \
                                     "sgrid[" + str(i) + "] is " + \
                                     str(type(sgrid[i])) + \
                                     " that is not iterable."
                try:
                    expression = "".join(["lambda pos:", sgrid[i]])
                    fgrid[i] = eval(expression, globals())
                except Exception, x:
                    err = x
            # We got an error that may be caused by a global assignment ("a=2")
            if err is not None and '=' in sgrid[i]:
                try:
                    stripped_string = sgrid[i].split('=', 1)
                    if stripped_string[0] in ['S', 'X', 'Y', 'Z']:
                        raise SyntaxError, \
                              'Cell name must not mask built-in global.'
                    exec("=".join(stripped_string[:2])) in globals()
                    exec("".join(["global ", stripped_string[0]])) in globals()
                    evalstring = "".join(["lambda pos:", stripped_string[1]])
                    fgrid[i] = eval(evalstring, globals())
                    err = None
                except Exception, x:
                    err = x
            if err is not None:
                fgrid[i] = lambda x: err

    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 GetMacro(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 AddToMacroDict(self, code):
        """ Adds a macro with the code string 'code' to the macro dict"""
        func = self.GetMacro(code)
        if func.__name__ in self:
            if DEBUG: print "Macro already present."
            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)
            #print undo_operation + operation

# end of class UnRedo
