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

#  Pybik -- A 3 dimensional magic cube game.
#  Copyright © 2009, 2011-2013  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/>.

# Ported from GNUbik
# Original filename: guile-hooks.c
# Original copyright and license: 2004  Dale Mellor, GPL3+



import os
import sys
import imp
from collections import namedtuple, OrderedDict

from .debug import *    # pylint: disable=W0401, W0614
from . import config
from .moves import MoveQueue
from . import model

FILE_VERSION = '1.1'


class PluginState (object):
    def __init__(self, app):
        self.initial_state = None
        self.current_state = app.gamestate.current_cube_state.copy()
        self.moves = MoveQueue()
        self.error_dialog = app.error_dialog
        self.set_progress = app.set_progress
        self.end_progress = app.end_progress
        
    def add_moves(self, moves):
        for move in moves:
            self.current_state.rotate_slice(move)
            self.moves.push(move)
            
    def add_flubrd(self, code):
        #TODO: here flubrd should be used, not the one in all_moves,
        # the converter in all_moves may be configurable in the future
        for res in self.moves.parse_iter(code, len(code), self.current_state.model):
            self.current_state.rotate_slice(res[0])
            
    def random(self, count=-1):
        self.current_state.set_solved()
        self.current_state.randomize(count)
        self.initial_state = self.current_state.copy()
        self.moves.reset()
        
    def test_model(self, models, show_error=True):
        cube = self.current_state
        for unused_modelstr, Model, sizes, exp in models:
            if Model == '*':
                return True
            if Model is None:
                continue
            if cube.type != Model.type:
                continue
            sizedict = {}
            for size1, size2 in zip(sizes, cube.model.sizes):
                if size1 is None:
                    continue
                if type(size1) is int:
                    if size1 != size2:
                        break
                else:
                    sizedict[size1] = size2
            else:
                try:
                    if exp is None or eval(exp, {}, sizedict):
                        return True
                except ValueError:
                    continue
        if not show_error:
            return False
        models = [model_[0] for model_ in models if model_[1] is not None]
        if not models:
            self.error_dialog(_('This algorithm does not work for any model.\n'))
        elif len(models) == 1:
            self.error_dialog(_('This algorithm only works for:\n') + '  ' + models[0])
        else:
            self.error_dialog(
                    _('This algorithm only works for:\n') +
                    '\n'.join([' • ' + m for m in models])
                )
        return False
        
        
class PluginHelper (object):
    def __init__(self):
        self.scripts = OrderedDict()
        self.error_messages = []
        
    def call(self, app, index):
        plugin_state = PluginState(app)
        all_models = []
        for models, func in list(self.scripts.values())[index]:
            if plugin_state.test_model(models, show_error=False):
                func(plugin_state)
                break
            all_models += models
        else:
            plugin_state.test_model(all_models, show_error=True)
        return plugin_state.initial_state, plugin_state.moves
        
    def load_plugins_from_directory(self, dirname):
        if not os.path.isdir(dirname):
            debug('Plugins path does not exist:', dirname)
            return
        debug('Loading Plugins from', dirname)
        sys.path.insert(0, dirname)
        for filename in sorted(os.listdir(dirname), key=str.lower):
            unused_name, ext = os.path.splitext(filename)
            if ext != '.algorithm':
                continue
            if DEBUG_ALG: debug('  algorithm:', filename)
            try:
                self.load_text_plugin(os.path.join(dirname, filename))
            except Exception as e:      # pylint: disable=W0703
                self.error_messages.append('Error loading {}:\n{}'.format(os.path.basename(filename), e))
                sys.excepthook(*sys.exc_info())
        
    def load_plugins(self):
        '''This function initializes the plugins for us, and once the plugins have
        all been imported, it returns the requested menu structure to the caller.'''
        self.scripts.clear()
        del self.error_messages[:]
        self.load_plugins_from_directory(config.SCRIPT_DIR)
        self.load_plugins_from_directory(config.USER_SCRIPT_DIR)
        if DEBUG_ALG: debug('found', len(self.scripts))
        return [(path, i) for i, path in enumerate(self.scripts.keys())]
        
    @staticmethod
    def parse_text_plugin(filename):
        with open(filename, 'rt', encoding='utf-8') as fp:
            lines = fp.readlines()
        paras = []
        
        # parse file
        para = {}
        key = None
        for line in lines:
            line = line.rstrip()
            if not line:
                # end para
                if para:
                    paras.append(para)
                para = {}
            elif line[0] == '#':
                # comment
                pass
            elif line[0] in ' \t':
                line = line.strip()
                if para and line[0] != '#':
                    # multiline
                    para[key].append(line)
            else:
                key, value = line.split(':')
                value = value.strip()
                para[key] = [value] if value else []
        if para:
            paras.append(para)
        return paras
        
    @staticmethod
    def eval_model(modelstrings):
        model_infos = []
        for modelstr in modelstrings:
            try:
                model_info = model.from_string(modelstr)
            except ValueError as e:
                debug('Error in model %s:' % modelstr, e)
            else:
                model_infos.append(model_info)
        return model_infos
        
    @classmethod
    def eval_header(cls, header):
        value = header.get('File-Version', [])
        if len(value) == 0:
            value = FILE_VERSION
            debug('No file version found, assume version', value)
        elif len(value) == 1:
            value = value[0]
        else:
            debug('Multiple version numbers found:\n ', '\n  '.join(value))
            value = value[0]
            debug('assume version', value)
        if value != FILE_VERSION:
            debug('Wrong file version:', value)
            return None, None
        models = header.get('Model', [])
        models = cls.eval_model(models)
        
        ref_blocks = header.get('Ref-Blocks', None)
        if ref_blocks:
            ref_blocks = ' '.join(ref_blocks).split()
        return models, ref_blocks
        
    @classmethod
    def eval_paras(cls, models, ref_blocks, paras):
        for para in paras:
            value = para.get('Path', ())
            if len(value) != 1 or not value[0]:
                if DEBUG_ALG: debug('    skip Path:', value)
                para.clear()
                continue
            def split_path(value):
                if not value:
                    return None, ()
                sep = value[0]
                value = value.strip(sep).split(sep)
                return sep, tuple(v for v in value if v)
            sep, value = split_path(value[0])
            if not value:
                if DEBUG_ALG: debug('    skip Path:', value)
                para.clear()
                continue
            if DEBUG_ALG: debug('    Path:', value)
            para['Path'] = sep, value
            
            value = para.get('Depends', [])
            value = [split_path(v)[1] for v in value]
            para['Depends'] = value
            
            value = para.get('Model', None)
            para['Model'] = models if value is None else cls.eval_model(value)
            
            value = para.get('Ref-Blocks', None)
            if value:
                value = ' '.join(value).split()
            para['Ref-Blocks'] = value or ref_blocks
            
            value = para.get('Solution', None)
            def split_solution(value):
                value = value.split('#', 1)[0]
                value = value.split(',', 1)
                if len(value) != 2:
                    return None
                cond = value[0].strip().split(' ')
                cond = [c.split('=') for c in cond if c]
                cond = [c for c in cond if len(c) == 2]
                return cond, value[1].strip()
            if value is not None:
                value = [split_solution(v) for v in value if v]
            para['Solution'] = value
            
            value = para.get('Moves', None) or None
            if value is not None:
                value = ' '.join(value)
            para['Moves'] = value
            
            value = para.get('Module', None) or None
            if value is not None:
                value = value[0]
            para['Module'] = value
            
    def load_text_plugin(self, filename):
        paras = self.parse_text_plugin(filename)
        if not paras:
            return
            
        # evaluate header
        models, ref_blocks = self.eval_header(paras[0])
        if models is None:
            return
            
        # evaluate solutions
        self.eval_paras(models, ref_blocks, paras)
        
        scripts = []
        for para in paras:
            if not para:
                continue
            sep, path = para['Path']
            models = para['Model']
            depends = para['Depends']
            solution = para['Solution']
            moves = para['Moves']
            module = para['Module']
            if depends or solution is not None:
                if moves is not None:
                    debug('    solution can not have moves:', path)
                if module is not None:
                    debug('    solution can not have a module field:', path)
                params = ScriptParams(
                                depends=depends,
                                ref_blocks=para['Ref-Blocks'],
                                solution=solution,
                                scripts=scripts,
                                models=models,
                                sep_path=(sep, path),
                            )
                scripts.append((path, sep!='@', models, ScriptFactory(self, params)))
            elif moves is not None:
                if module is not None:
                    debug('    Moves can not have a module field:', path)
                def play_moves(moves, models):
                    def func(game):
                        if game.test_model(models):
                            game.add_flubrd(moves)
                    return func
                scripts.append((path, True, models, play_moves(moves, models)))
            elif module is not None:
                dirname = os.path.dirname(filename)
                modulename, funcname = module.split(' ', 1)
                mfile, mpath, mdesc = imp.find_module(modulename, [dirname])
                moduleobj = imp.load_module(modulename, mfile, mpath, mdesc)
                modulefunc = getattr(moduleobj, funcname)
                scripts.append((path, True, models, modulefunc))
            else:
                debug('    skip Path without algorithm:', path)
            
        for path, visible, models, func in scripts:
            if visible:
                funclist = self.scripts.setdefault(path, [])
                funclist.append((models, func))
        
        
ScriptParams = namedtuple('ScriptParams', 'depends ref_blocks solution scripts models sep_path')

class ScriptFactory (object):
    def __init__(self, plugin, params):
        self.solved_face_colors = {}
        self.plugin = plugin
        self.params = params
        
    def __call__(self, plugin_state):
        if not plugin_state.test_model(self.params.models):
            return
            
        depends = list(reversed(self.params.depends))
        scripts = {path: func for path, visible, models, func in self.params.scripts}
        for depend in depends:
            instance = scripts[depend]
            depends += instance.params.depends
        for depend in reversed(depends):
            instance = scripts[depend]
            self.execute(plugin_state, instance.params)
        self.execute(plugin_state, self.params)
        
    def test_face(self, cube, blocksym, condition, face):
        try:
            color2 = self.solved_face_colors[condition[face]]
        except KeyError:
            return True
        blocknum = cube.model.block_symbolic_to_block_index(blocksym)
        color1 = cube.get_colorsymbol(blocknum, blocksym[face])
        return color1 == color2
        
    def test_basic_condition(self, cube, position, condition):
        assert len(position) == len(condition)
        for i in range(len(position)):
            if not self.test_face(cube, position, condition, i):
                return False
        return True
        
    @staticmethod
    def opposite(face):
        return {  'f': 'b', 'b': 'f',
                  'l': 'r', 'r': 'l',
                  'u': 'd', 'd': 'u',
               }[face]
        
    def test_pattern_condition(self, cube, position, condition):
        if '?' in condition:
            conditions = (condition.replace('?', face, 1)
                            for face in 'flubrd'
                                if face not in condition
                                if self.opposite(face) not in condition)
            return self.test_or_conditions(cube, position, conditions)
        else:
            return self.test_basic_condition(cube, position, condition)
            
    @staticmethod
    def rotated_conditions(condition):
        for i in range(len(condition)):
            yield condition[i:] + condition[:i]
        
    def test_prefix_condition(self, cube, position, condition):
        if condition.startswith('!*'):
            return not self.test_or_conditions(cube, position, self.rotated_conditions(condition[2:]))
        elif condition.startswith('*'):
            #TODO: Instead of testing only rotated conditions, test all permutations.
            #      This should not break existing rules, and would allow to match
            #      e.g. dfr and dfl. Could be done by comparing sorted strings after
            #      expanding patterns.
            return self.test_or_conditions(cube, position, self.rotated_conditions(condition[1:]))
        elif condition.startswith('!'):
            return not self.test_pattern_condition(cube, position, condition[1:])
        else:
            return self.test_pattern_condition(cube, position, condition)
        
    def test_or_conditions(self, cube, position, conditions):
        for prefix_cond in conditions:
            if self.test_prefix_condition(cube, position, prefix_cond):
                return True
        return False
        
    def test_and_conditions(self, cube, conditions):
        for position, or_cond in conditions:
            if not self.test_or_conditions(cube, position, or_cond.split('|')):
                return False
        return True
        
    def execute(self, plugin_state, params):
        rules = params.solution
        if rules is None:
            return
        cube = plugin_state.current_state
        count = 0
        pos = 0
        while pos < len(rules):
            self.solved_face_colors.clear()
            for block in params.ref_blocks:
                blocknum = cube.model.block_symbolic_to_block_index(block)
                for face in block:
                    self.solved_face_colors[face] = cube.get_colorsymbol(blocknum, face)
            conditions, moves = rules[pos]
            if self.test_and_conditions(cube, conditions):
                if DEBUG_ALG:
                    print('{}: accept: {:2}. {}, {}'.format(
                                params.sep_path[0].join(params.sep_path[1]),
                                pos+1,
                                ' '.join('='.join(c) for c in conditions),
                                moves))
                if moves == '@@solved':
                    return
                if count > 4 * len(rules): # this value is just guessed
                    plugin_state.error_dialog(
                        'An infinite loop was detected. '
                        'This is probably an error in the solution.')
                    return
                count += 1
                
                plugin_state.add_flubrd(moves)
                pos = 0
            else:
                pos += 1
        plugin_state.error_dialog(
            'No matching rules found. '
            'This is probably an error in the solution.')
        
        
