#-*- coding:utf-8 -*-

#  Copyright © 2009-2015, 2017  B. Clausius <barcc@gmx.de>
#
#  This program 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.
#
#  This program 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 this program.  If not, see <http://www.gnu.org/licenses/>.


import re
from collections import namedtuple

from .debug import debug


class MoveData (namedtuple('_MoveData', 'axis slice dir')):     # pylint: disable=W0232
    # axis  = 0...
    # slice = 0...dim-1 or -1 for all slices
    # dir   = 0 or 1
    __slots__ = ()
    def inverted(self):
        return self._replace(dir=not self.dir)  # pylint: disable=E1101
        

def normalized_dirs(model, axis, dirs):
    symmetry = model.symmetries[axis]
    dirs = dirs % symmetry
    if dirs > symmetry // 2:
        dirs -= symmetry
    return dirs
    
class MoveDataPacked (namedtuple('_MoveDataPacked', 'axis slice dirs')):
    # axis  = 0...
    # slice = 0...dim-1, (-1 for all slices not allowed in some functions)
    # dirs = -symmetry/2...+symmetry/2, >0 CW <0 CCW
    __slots__ = ()
    
    def __new__(self, maxis, mslice, mdirs, model=None):
        if model is not None:
            mdirs = normalized_dirs(model, maxis, mdirs)
        return super().__new__(self, maxis, mslice, mdirs)
        
    def length(self):
        return abs(self.dirs)
        
    def add_dirs(self, dirs, model):
        return self._replace(dirs=normalized_dirs(model, self.axis, self.dirs+dirs))
        
    def unpack(self):
        mdir = self.dirs < 0
        for unused in range(self.length()):
            yield self.axis, self.slice, mdir
            
        
class _MoveQueueBase:
    class MoveQueueItem:
        def __init__(self, data, *, mark_after=False, code=None):
            assert isinstance(data, (MoveData, MoveDataPacked))
            self.data = data
            self.mark_after = mark_after
            self.code = code
        axis = property(lambda self: self.data.axis)
        slice = property(lambda self: self.data.slice)
        dir = property(lambda self: self.data.dir)
        def copy(self):
            return self.__class__(self.data, mark_after=self.mark_after, code=self.code)
        def invert(self):
            self.data = self.data.inverted()
        def rotate_by(self, model, totalmove):
            rmove = model.rotate_move(totalmove.data, self.data)
            self.data = self.data.__class__(*rmove)
            self.code = None
        @classmethod
        def normalize_complete_moves(cls, model, totalmoves):
            for move in model.normalize_complete_moves(move.data for move in totalmoves):
                yield cls(MoveData(*move))
        def update_fullmoves(self, model):
            self.data = self.data.update_fullmoves(model)
            
    def __init__(self):
        self.current_place = self._pos_0
        self.moves = []
        
    def copy(self):
        movequeue = self.__class__()
        movequeue.current_place = self.current_place
        movequeue.moves = [m.copy() for m in self.moves]
        return movequeue
        
    def __str__(self):
        return '{}(len={})'.format(self.__class__.__name__, len(self.moves))
        
        
class MoveQueue (_MoveQueueBase):
    _pos_0 = 0
    
    @classmethod
    def new_from_code(cls, code, pos, model):
        moves = cls()
        mpos, cpos = moves.parse(code, pos, model)
        return moves, mpos, cpos
        
    def at_start(self):
        return self.current_place == 0
    def at_end(self):
        return self.current_place == self.queue_length
    @property
    def _prev(self):
        return self.moves[self.current_place-1]
    @property
    def _current(self):
        return self.moves[self.current_place]
    @property
    def queue_length(self):
        return len(self.moves)

    def push(self, move_data, **kwargs):
        new_element = self.MoveQueueItem(MoveData(*move_data), **kwargs)
        self.moves.append(new_element)

    def push_current(self, move_data):
        if not self.at_end():
            self.moves[self.current_place:] = []
        self.push(move_data)

    def current(self):
        return None if self.at_end() else self._current.data

    def retard(self):
        if not self.at_start():
            self.current_place -= 1
    
    def rewind_start(self):
        self.current_place = 0
        
    def forward_end(self):
        self.current_place = self.queue_length
        
    def truncate(self):
        self.moves[self.current_place:] = []
        
    def truncate_before(self):
        self.moves[:self.current_place] = []
        self.current_place = 0
        
    def reset(self):
        self.current_place = 0
        self.moves[:] = []
        
    def advance(self):
        if not self.at_end():
            self.current_place += 1
        
    def swapnext(self):
        cp = self.current_place
        if cp+1 < self.queue_length:
            ma, mb = self.moves[cp], self.moves[cp+1]
            ma.code = mb.code = None
            ma.data, mb.data = mb.data, ma.data
            self.advance()
            
    def swapprev(self):
        cp = self.current_place
        if 0 < cp < self.queue_length:
            ma, mb = self.moves[cp-1], self.moves[cp]
            ma.code = mb.code = None
            ma.data, mb.data = mb.data, ma.data
            self.retard()
            
    def invert(self):
        # a mark at the end of the moves is discarded because a mark at start is not supported
        mark = False
        for move in self.moves:
            move.invert()
            move.code = None
            move.mark_after, mark = mark, move.mark_after
        self.moves.reverse()
        if not (self.at_start() or self.at_end()):
            self.current_place = len(self.moves) - self.current_place
            
    def normalize_complete_rotations(self, model):
        totalmoves = []
        new_moves = []
        for i, move in enumerate(self.moves):
            if i == self.current_place:
                self.current_place = len(new_moves)
            if move.slice < 0:
                totalmoves.append(move)
            else:
                for totalmove in reversed(totalmoves):
                    move.rotate_by(model, totalmove)
                new_moves.append(move)
        totalmoves = list(self.MoveQueueItem.normalize_complete_moves(model, totalmoves))
        self.moves = new_moves + totalmoves
        
    def push_packed(self, pmove):
        for move in pmove.data.unpack():
            self.push(move)
        
    def normalize_moves(self, model):
        mqp = MoveQueuePacked()
        mqp_current_place = (0, 0)
        for i, move in enumerate(self.moves):
            if i == self.current_place:
                mqp_current_place = (len(mqp.moves)-1, mqp.moves[-1].data.length()) if mqp.moves else (0, 0)
            mqp.push_unpacked(move.data, model)
        mqp_moves = mqp.separate_full_moves(model)
        if len(self.moves) <= self.current_place:
            mqp_current_place = len(mqp_moves), 0
        self.reset()
        for i, move in enumerate(mqp_moves):
            if i == mqp_current_place[0]:
                self.current_place = len(self.moves) + mqp_current_place[1]
            self.push_packed(move)
        if len(mqp_moves) <= mqp_current_place[0]:
            self.current_place = len(self.moves)
        
    def is_mark_current(self):
        return self.at_start() or self._prev.mark_after
        
    def is_mark_after(self, pos):
        return self.moves[pos].mark_after
        
    def mark_current(self, mark=True):
        if not self.at_start():
            self._prev.mark_after = mark
            if self._prev.code is not None:
                self._prev.code = self._prev.code.replace(' ','')
                if mark:
                    self._prev.code += ' '
                    
    def mark_and_extend(self, other):
        if not other.moves:
            return -1
        self.mark_current()
        self.truncate()
        self.moves += other.moves
        return self.current_place + other.current_place
        
    def format(self, model):
        code = ''
        pos = 0
        for i, move in enumerate(self.moves):
            if move.code is None:
                move.code = MoveFormat.format(move, model)
            code += move.code
            if i < self.current_place:
                pos = len(code)
        return code, pos
        
    def parse_iter(self, code, pos, model):
        code = code.lstrip(' ')
        queue_pos = self.current_place
        move_code = ''
        for i, c in enumerate(code):
            if move_code and MoveFormat.isstart(c, model):
                data, mark = MoveFormat.parse(move_code, model)
                if data is not None:
                    #FIXME: invalid chars at start get lost, other invalid chars are just ignored
                    self.push(data, mark_after=mark, code=move_code)
                yield data, queue_pos, i
                if i == pos:
                    queue_pos = self.queue_length
                move_code = c
            else:
                move_code += c
            if i < pos:
                queue_pos = self.queue_length + 1
        if move_code:
            data, mark = MoveFormat.parse(move_code, model)
            if data is not None:
                self.push(data, mark_after=mark, code=move_code)
            if len(code)-len(move_code) < pos:
                queue_pos = self.queue_length
            yield data, queue_pos, len(code)
            
    def parse(self, code, pos, model):
        queue_pos = 0
        cpos = 0
        for unused_data, queue_pos, code_len in self.parse_iter(code, pos, model):
            if cpos < pos:
                cpos = code_len
        return queue_pos, cpos
        
        
class MoveQueuePacked (_MoveQueueBase):
    _pos_0 = (0, 0, 0)
    
    def length(self):
        i = 0
        for move in self.moves:
            i += move.data.length()
        return i
        
    def axis_length(self, axis_dirs, axis, model):
        symmetry = model.symmetries[axis]
        if model.ignored_slice[axis] is None:
            vals = [0] * symmetry
            for m_dirs in axis_dirs:
                for s_base in range(symmetry):
                    v = m_dirs + s_base
                    if v > symmetry//2:
                        v -= symmetry
                    vals[s_base] += abs(v)
            minval = vals[0]+1
            for i, v in enumerate(vals):
                if v < minval:
                    f = i
                    minval = v
            f = symmetry - f
            if f > symmetry//2:
                f -= symmetry
            return minval, f
        else:
            f = axis_dirs[model.ignored_slice[axis]]
            length = 0
            for m_dirs in axis_dirs:
                v = m_dirs - f + symmetry
                if v > symmetry//2:
                    v -= symmetry
                length += abs(v)
            return length, f
            
    def length_no_full(self, model):
        i = 0
        axis = None
        axis_dirs = None
        for move in self.moves:
            if axis != move.data.axis:
                if axis is not None:
                    i += self.axis_length(axis_dirs, axis, model)[0]
                axis = move.data.axis
                axis_dirs = [0] * model.sizes[axis]
            assert move.data.slice >= 0
            assert axis_dirs[move.data.slice] == 0
            axis_dirs[move.data.slice] = move.data.dirs
        if axis is not None:
            i += self.axis_length(axis_dirs, axis, model)[0]
        return i
        
    def _separate_full_moves(self, i_axis, j_axis, axis, fullmoves, model):
        if not fullmoves:
            return self.moves[i_axis:j_axis]
        mslice = 0
        newmoves = []
        for i in range(i_axis, j_axis):
            move = self.moves[i]
            for j in range(mslice, move.data.slice):
                moveitem = self.MoveQueueItem(MoveDataPacked(axis, j, -fullmoves, model))
                newmoves.append(moveitem)
            assert axis == move.data.axis
            move.data = move.data.add_dirs(-fullmoves, model)
            newmoves.append(move)
            mslice = move.data.slice + 1
        for j in range(mslice, model.sizes[axis]):
            moveitem = self.MoveQueueItem(MoveDataPacked(axis, j, -fullmoves, model))
            newmoves.append(moveitem)
        moveitem = self.MoveQueueItem(MoveDataPacked(axis, -1, fullmoves))
        newmoves.append(moveitem)
        return newmoves
        
    def separate_full_moves(self, model):
        i_axis = 0
        axis = None
        axis_dirs = None
        newmoves = []
        for i, move in enumerate(self.moves):
            if axis != move.data.axis:
                if axis is not None:
                    fullmoves = self.axis_length(axis_dirs, axis, model)[1]
                    newmoves += self._separate_full_moves(i_axis, i, axis, fullmoves, model)
                i_axis = i
                axis = move.data.axis
                axis_dirs = [0] * model.sizes[axis]
            assert move.data.slice >= 0
            assert axis_dirs[move.data.slice] == 0, (axis_dirs, i, move.data.slice)
            axis_dirs[move.data.slice] = move.data.dirs
        if axis is not None:
            fullmoves = self.axis_length(axis_dirs, axis, model)[1]
            newmoves += self._separate_full_moves(i_axis, len(self.moves), axis, fullmoves, model)
        return newmoves
        
    def push_unpacked(self, move, model):
        # this function assumes that self.moves does not contain full moves,
        if move.slice < 0:
            # resolve full moves to slice moves
            for i in range(model.sizes[move.axis]):
                self.push_unpacked(MoveData(move.axis, i, move.dir), model)
            return
            
        i = len(self.moves)
        while True:
            if i <= 0:
                moveitem = None
                break
            # get item
            _moveitem = self.moves[i-1]
            parallel = model.parallel(_moveitem.data, move)
            if parallel == parallel.NO or parallel == parallel.LT:
                moveitem = None
                break
            moveitem = _moveitem
            i -= 1
            if parallel == parallel.COMPAT:
                break
            assert parallel == parallel.GT
        # create new empty item if needed
        if moveitem is None:
            moveitem = self.MoveQueueItem(MoveDataPacked(move.axis, move.slice, 0))
            self.moves.insert(i, moveitem)
        # combine move with the compatible moveitem.data
        assert move.axis == moveitem.data.axis
        moveitem.data = moveitem.data.add_dirs((-1 if move.dir else 1), model)
        # remove if empty
        if moveitem.data.dirs == 0:
            self.moves.pop(i)
            
    def format(self, model):
        mq = MoveQueue()
        for move in self.moves:
            mq.push_packed(move)
        return mq.format(model)
        
                
class MoveFormat: # pylint: disable=W0232
    re_flubrd = re.compile(r"(.)(\d*)(['-]?)([^ ]*)( *)(.*)")
    
    @classmethod
    def isstart(cls, char, model):
        return char.upper() in model.faces
    
    @staticmethod
    def intern_to_str_move(move, model):
        if move.slice <= -1:
            # Rotate entire cube
            if move.dir:
                mface = model.symbolsI[move.axis]
                if mface in model.faces:
                    # opposite symbol not reversed
                    return mface, '', ''
                # no opposite symbol
            mface = model.symbols[move.axis]
            mdir = '-' if move.dir else ''
            return mface, '', mdir
        elif move.slice*2 > model.sizes[move.axis]-1:
            mface = model.symbolsI[move.axis]
            if mface in model.faces:
                # slice is nearer to the opposite face
                mface = mface.lower()
                mslice = model.sizes[move.axis]-1 - move.slice
                mslice = str(mslice+1) if mslice else ''
                mdir = '' if move.dir else '-'
                return mface, mslice, mdir
            # no opposite face
        mface = model.symbols[move.axis].lower()
        mslice = str(move.slice+1) if move.slice else ''
        mdir = '-' if move.dir else ''
        return mface, mslice, mdir
    
    @classmethod
    def format(cls, move, model):
        mface, mslice, mdir = cls.intern_to_str_move(move, model)
        mark = ' ' if move.mark_after else ''
        move_code = mface + mslice + mdir + mark
        return move_code
        
    @staticmethod
    def str_to_intern_move(tface, tslice, tdir, model):
        mface = tface.upper()
        mslice = int(tslice or 1) - 1
        mdir = bool(tdir)
        if mface not in model.faces:
            return None
        elif mface in model.symbols:
            maxis = model.symbols.index(mface)
        elif mface in model.symbolsI:
            maxis = model.symbolsI.index(mface)
            mslice = model.sizes[maxis]-1 - mslice
            mdir = not mdir
        else:
            assert False, 'faces is a subset of symbols+symbolsI'
        if mslice < 0 or mslice >= model.sizes[maxis]:
            return None
        if tface.isupper():
            mslice = -1
        return MoveData(maxis, mslice, mdir)
        
    @classmethod
    def parse(cls, move_code, model):
        tface, tslice, tdir, unused_err1, mark, unused_err2 = cls.re_flubrd.match(move_code).groups()
        mark = bool(mark)
        move = cls.str_to_intern_move(tface, tslice, tdir, model)
        if move is None:
            debug('Error parsing formula')
            return move, False
        return move, mark
        
        
