#!/usr/bin/python
# -*- coding: utf-8 -*-

# Copyright (C) 2018 Rod Engerstad


from random import randint
import tkinter as tk
import tkinter.colorchooser

from pzl_lib import PzlG
from pzl_lib import SettingsB
from pzl_lib import Preferences
from pzl_lib import MenuItem
from pzl_lib import Flasher
from pzl_lib import ScreenShot
from pzl_lib import OpenFileI
from pzl_lib import ViewB
from pzl_lib import Square
from pzl_lib import Egg
from sudoku_model import Sgg
from sudoku_model import SudokuModel
from jigsawsudoku_data import ModelData


class Settings(SettingsB):

    def __init__(self):
        SettingsB.__init__(self)
        self.dic = {}
        self.dic['box_0_colour'] = '#7FFF7F'
        self.dic['box_1_colour'] = '#7FFFD4'
        self.dic['box_2_colour'] = '#7FD4FF'
        self.dic['box_3_colour'] = '#D4FF7F'
        self.dic['box_4_colour'] = '#FF7FD4'
        self.dic['box_5_colour'] = '#7F7FFF'
        self.dic['box_6_colour'] = '#D47FFF'
        self.dic['box_7_colour'] = '#FFD47F'
        self.dic['box_8_colour'] = '#FF7F7F'
        self.dic['text_colour'] = '#000000'
        self.dic['cause_colour'] = '#00d341'
        self.dic['effect_colour'] = '#ff0000'
        self.dic['button_colour'] = '#82c026'
        self.dic['error_colour'] = '#ff0000'
        self.dic['hint_mode'] = 'simplest_hint'
        self.dic['save_puzzle'] = 'without_candidates'
        self.dic['set_colour'] = 'box_0_colour'
        self.dic['font_size'] = '2'
        self.original_square_size = 54
        self.square_size = self.original_square_size
        self.menu_items = []

        self.preferences = Preferences(self.dic)
        self.preferences.load()
        if self.dic['hint_mode'] == 'simplest_hint':
            Sgg.simplest_hint = True
        else:
            Sgg.simplest_hint = False
        self.load_menus()
        self.count = len(self.menu_items)
        self.set_fonts()
        self.make_help()
        self.make_tooltip()
        self.make_alg_help()

    def make_alg_help(self):
        self.alg_text = [_("The exclusion rule: only one of each digit can occur in any row, column or box, so once an answer is set all other occurrences of that digit in its row, column and box can be deleted."), _("Hidden single: all rows, columns and boxes must contain exactly one of each of the digits; if a cell contains the only remaining copy of a digit in a row, column or box, that digit must be the answer for that cell, and any other digits in that cell can be deleted."), _("Naked pairs: all rows, columns and boxes must contain exactly one of each of the digits; if a pair of cells contain the only copies of two digits in a row, column or box, those digits must be the answers for the two cells, and any other occurrences of those digits in the corresponding row, column or box can be deleted."), _("Naked triples: all rows, columns and boxes must contain exactly one of each of the digits; if three cells contain the only copies of three digits in a row, column or box, those digits must be the answers for the three cells, and any other occurrences of those digits in the corresponding row, column or box can be deleted."), _("Hidden pairs: all rows, columns and boxes must contain exactly one of each of the digits; if a pair of cells contain the only copies of two digits in a row, column or box, those digits must be the answers for the two cells, and any other digits in those cells can be deleted."), _("Hidden triples: all rows, columns and boxes must contain exactly one of each of the digits; if three cells contain the only copies of three digits in a row, column or box, those digits must be the answers for the three cells, and any other digits in those cells can be deleted."), _("Naked quads: all rows, columns and boxes must contain exactly one of each of the digits; if four cells contain the only copies of four digits in a row, column or box, those digits must be the answers for the four cells, and any other occurrences of those digits in the corresponding row, column or box can be deleted."), _("X wing: all rows and columns must contain exactly one of each of the digits; if cells in two rows (or columns) contain the only copies of one digit in those two rows (or columns) and those four cells form a rectangle, then that digit is the answer for two of those cells, one in each of the intersecting columns (or rows), and other occurrences of that digit in the two columns (or rows) can be deleted."), _("Known answer!")]

    def make_tooltip(self):
        self.tooltip_text = [
            _('Left-mouse random puzzle, right-mouse next puzzle'),
            _('Left mouse: give hint, right mouse: simple filter'),
            _('Check for errors, correct errors'),
            _('Show solution'),
            ]

    def make_help(self):
        self.help_text = _("The program starts with a randomly selected puzzle displayed. The 9 possible candidate answers are written in the unsolved squares. These numbers are buttons: left-clicking will delete a candidate, middle-clicking on its position will rewrite it, right-clicking will set it as the answer and delete any other candidates present. In the toolbar the jigsaw symbol contains a menu, the next symbol starts a new game; the wand requests a hint, a second click on the wand will perform the hint operation; the thumb checks if the puzzle contains any errors and a second click will clear the errors; the sad smiley shows the solution; the entrybox shows the current puzzle number and can be used to select a new puzzle number; the number next to the puzzle number is the puzzle difficulty score which depends on the algorithms required to find its solution. The clock ticks every 5 seconds but will be replaced by the algorithm number if a hint is requested. If the player right-clicks on the hint button, the program will repeatedly apply simple filter until it has no effect, clearing the candidates as it goes. Otherwise, a left click on the hint button will request a single hint. While the hint is active (shown by the wand icon leaning backwards) clicking on the wand with the middle mouse button will cause a pop-up to appear containing a short explanation of the hint. Hints are shown by highlighting the candidates that can be deleted in red and the reasons in green. A further left click will delete the red candidates and unhighlight the green candidates. The program contains 1000 puzzles.")

    def load_menus(self):
        self.menu_items.append(MenuItem(
            _('Hint Mode'),
            [_('Simplest'), _('Most Effective')],
            '',
            ['simplest_hint', 'most_effective_hint'],
            'hint_mode'
        ))
        self.menu_items.append(MenuItem(
            _('Font Size'),
            ['1',
             '2',
             '3',
             '4',
             '5',
             '6',
             '7',
             '8',
             '9',
             '10',
             '11',
             '12',
             '13'],
            '',
            ['1',
             '2',
             '3',
             '4',
             '5',
             '6',
             '7',
             '8',
             '9',
             '10',
             '11',
             '12',
             '13'],
            'font_size'
            ))
        self.menu_items.append(MenuItem(
            _('Set Colour'),
            [_('box 0'),
             _('box 1'),
             _('box 2'),
             _('box 3'),
             _('box 4'),
             _('box 5'),
             _('box 6'),
             _('box 7'),
             _('box 8'),
             _('Button'),
             _('Cause'),
             _('Effect'),
             _('Error'),
             _('Text')],
            'N',
            ['box_0_colour',
             'box_1_colour',
             'box_2_colour',
             'box_3_colour',
             'box_4_colour',
             'box_5_colour',
             'box_6_colour',
             'box_7_colour',
             'box_8_colour',
             'button_colour',
             'cause_colour',
             'effect_colour',
             'error_colour',
             'text_colour'],
            'set_colour'
        ))
        self.menu_items.append(MenuItem(
            _('Save Settings'),
            '',
            '',
            '',
            'save_settings'
        ))
        self.menu_items.append(MenuItem(
            _('Help'),
            '',
            '',
            '',
            'help'
        ))
        self.menu_items.append(MenuItem(
            _('About'),
            '',
            '',
            '',
            'about'
        ))


class Board(tk.Frame):
    def __init__(self, parent, settings, datum):
        self.settings = settings
        self.datum = datum
        self.grid_widgets = [None] * 81
        self.box_colours = {}
        bg = self.settings.black
        tk.Frame.__init__(self, parent, relief='flat',
                          highlightbackground=self.settings.grey,
                          highlightthickness=2,
                          bd=2, bg=bg)
        self.set_colours()
        square_index = 0
        grid_size = 9
        for row in range(grid_size):
            for column in range(grid_size):
                bg_colour = self.get_square_colour(square_index)
                widget = Square(self, self.settings,
                                self.datum[square_index],
                                bg_colour, square_index, row, column)
                self.grid_widgets[square_index] = widget
                square_index += 1
        self.grid(row=0, column=0)

    def my_destroy(self):
        self.destroy()

    def add_candidate_callback(self, func):
        for widget in self.grid_widgets:
            candidates = widget.candidates
            for candidate in candidates:
                candidate.callback = func

    def set_colours(self):
        self.box_colours = {}
        for key, item in list(self.settings.dic.items()):
            if key.find('box_') != -1:
                self.box_colours[int(key[key.index('box_') + 4])] = item

    def get_square_colour(self, datum_index):
        return self.box_colours[self.datum[datum_index].box]

    def update_square(self, update):
        """
        set some colours
        """
        operation = update.operation
        cell_id = update.cell_id
        widget = self.grid_widgets[cell_id]
        bg_colour = self.get_square_colour(cell_id)
        update.colour = bg_colour
        if operation == Egg.COLOUR_CANDIDATES_EFFECT:
            # colour effect candidates
            bg_colour = self.settings.dic['effect_colour']
            update.colour = bg_colour
        elif operation == Egg.COLOUR_CANDIDATES_CAUSE:
            # colour cause candidates
            bg_colour = self.settings.dic['cause_colour']
            update.colour = bg_colour
        elif operation == Egg.HIGHLIGHT_ERROR:
            bg_colour = self.settings.dic['error_colour']
            update.colour = bg_colour
        widget.my_update(update)


class View(ViewB):
    """
    Stick blobs on the screen and respond to callbacks
    """

    def __init__(self, master, settings):
        ViewB.__init__(self, master, settings)
        self.datum = None
        self.image_dic = {}
        self.make_image_dic()
        self.image_dic['start'] = [self.images.start_jigsawsudoku, 0, True]
        self.do_layout(self)
        self.entry_box.activate()

    def make_board(self):
        """
        draw the squares
        """
        if self.board is not None:
            self.board.my_destroy()
        self.board = Board(self.top_frame, self.settings, self.datum)

    def update_squares(self, updates):
        for update in updates:
            self.board.update_square(update)

    def add_candidate_callback(self, func):
        self.board.add_candidate_callback(func)

    def sg_open_file(self, write, title):
        """
        Use gui to open and file for reading or writing
        """
        self.entry_box.deactivate()
        init_dir = PzlG.app_saves
        filetypes = [('All files', '*')]
        this_file = OpenFileI(self, init_dir, filetypes, write, title)
        self.entry_box.activate()
        return this_file.my_file

    def update_clock_box(self, algorithm):
        names = ['SF', 'H1', 'N2', 'N3',
                 'H2', 'H3', 'N4', 'XW', '**']
        value = '  '
        if algorithm is not None:
            value = names[algorithm]
        self.clock.box.set_text(value)


class Datum(object):
    """
    Data item for a square
    """
    def __init__(self, my_index):
        self.candidates = set(range(9))
        self.given = False
        self.symbol_index = None
        self.guess = None
        self.index = my_index
        self.row = int(my_index / 9)
        self.column = my_index % 9
        self.box = None

    def add_symbol_index(self, symbol_index):
        self.symbol_index = symbol_index

    def reset(self):
        self.guess = None
        self.candidates = set(range(9))
        self.given = False

    def initialise(self, symbol_index, given, box):
        self.symbol_index = symbol_index
        self.box = box
        if given:
            self.given = True
            self.candidates = set()
            self.guess = symbol_index

    def set_guess(self, symbol_index, given):
        self.guess = symbol_index
        if given:
            self.given = True
        self.candidates = set()

    def unset_guess(self):
        self.guess = None

    def get_guess(self):
        return self.guess

    def guess_is_set(self):
        ret = True
        if self.guess is None:
            ret = False
        return ret

    def get_answer(self):
        answer = self.guess
        if answer is not None:
            answer += 1
        else:
            answer = 0
        return answer

    def get_candidates(self):
        return self.candidates

    def del_candidate(self, candidate):
        """
        return guess if deleting last candidate
        """
        ret = -1
        if not self.given:
            if candidate in self.candidates:
                self.candidates.remove(candidate)
            if self.guess is None:
                if len(self.candidates) == 1:
                    candidate = self.candidates.pop()
                    self.guess = candidate
                    ret = candidate
        return ret

    def add_candidate(self, candidate):
        ret = -1
        if not self.given:
            self.candidates.add(candidate)
            if self.guess is not None:
                ret = self.guess
                # next line 2017-06-13
                self.candidates.add(ret)
                self.guess = None
        return ret

    def other_candidates(self, candidate):
        return self.candidates.difference(candidate)

    def candidate_is_set(self, candidate):
        ret = False
        if candidate in self.candidates:
            ret = True
        return ret

    def is_consistent(self):
        """
        guess unset but no candidates left: bit 0
        guess set and candidate(s) True: bit 1
        guess != symbol_index: Error bit 2
        """
        error = 0
        if self.guess is None:
            if self.symbol_index is not None and \
                    self.symbol_index not in self.candidates:
                error = 1
        else:
            if self.candidates:
                error = 2

            if self.symbol_index is not None and \
                    self.symbol_index != self.guess:
                # guess != symbol_index
                error += 4
        return error


class Model(SudokuModel):

    def __init__(self, model_data):
        SudokuModel.__init__(self, model_data)
        self.algorithms = [(self.hidden_singles, 0),
                           (self.naked_pairs, 0),
                           (self.naked_triples, 0),
                           (self.hidden_pairs, 0),
                           (self.hidden_triples, 0),
                           (self.naked_quads, 0),
                           (self.x_wing, 1),
                           (self.random_answer, 2)]

    def init_datum(self):
        self.datum = []
        for datum_index in range(81):
            self.datum.append(Datum(datum_index))

    def create_indexes(self):
        """
        Set up useful arrays of datum indexes:
        1. rows
        2. columns
        3. boxes
        """
        self.row_indexes = []
        self.column_indexes = []
        self.box_indexes = []

        for datum_index in range(0,
                                 81,
                                 9):
            self.row_indexes.append(
                list(range(datum_index,
                           datum_index + 9)))

        for datum_index in range(9):
            self.column_indexes.append(
                list(range(datum_index,
                           81,
                           9)))

        self.box_indexes = [[] for _ in range(9)]
        for datum in self.datum:
            box = datum.box
            self.box_indexes[box].append(datum.index)

    def init_puzzle(self, puzzle_number):
        if puzzle_number is None or puzzle_number < 0 or \
                puzzle_number > self.model_data.puzzle_count - 1:
            puzzle_number = randint(0, self.model_data.puzzle_count - 1)
        self.puzzle_number = puzzle_number
        puzzle = self.fetch_selected_puzzle(puzzle_number)
        self.init_datum()
        self.score = puzzle[0]
        datum_index = -1
        for puzzle_index in range(1, 163, 2):
            datum_index += 1
            box = puzzle[puzzle_index]
            symbol_index = puzzle[puzzle_index + 1] - 1
            given = False
            if symbol_index < 0:
                given = True
                symbol_index = abs(symbol_index) - 2
            datum = self.datum[datum_index]
            datum.initialise(symbol_index, given, box)
        self.create_indexes()

    def apply_hint(self):
        if self.progress:
            for edit_pair in self.editor.effects:
                datum_index = edit_pair.datum.index
                candidates = edit_pair.candidates
                datum = self.datum[datum_index]
                for candidate in candidates:
                    guess = datum.del_candidate(candidate)

    def get_puzzle(self, puzzle_number):
        self.init_puzzle(puzzle_number)
        self.editor.set_mode(Sgg.HINT)

    def get_progress(self):
        """
        Get a list of the unsolved datum and their candidates.
        """
        undone = 0
        for datum in self.datum:
            if not datum.guess_is_set():
                undone += len(datum.candidates)
        return undone

    def get_unsolved_data(self):
        """
        Get a list of the unsolved datum and their candidates.
        """
        self.unsolved = []
        for datum in self.datum:
            if not datum.guess_is_set():
                pair = datum.index, datum.candidates
                self.unsolved.append(pair)


class Controller(object):
    """
    Set up and control
    """

    def __init__(self, root):
        self.settings = Settings()
        self.master = root
        self.model_data = ModelData()
        self.flasher = None
        self.model = None
        self.error_detected = False
        self.view = View(root, self.settings)
        self.coloured_squares = []
        screen_shot_state = False
        self.active = False
        self.screen_shot = ScreenShot(PzlG.app_name,
                                      'dump_', screen_shot_state)
        self.start_game(None)

    def start_game(self, puzzle_number):
        self.view.clock_init()
        self.view.clock_restart()
        self.view.game_over()
        self.model = Model(self.model_data)
        self.model.get_puzzle(puzzle_number)
        self.ready_for_hint()
        self.view.thumbs_up()
        self.view.datum = self.model.datum
        self.view.make_board()
        self.add_callbacks()
        self.view.game_on()
        self.error_detected = False
        self.view.entry_box.clear()
        self.view.entry_box.write_box(str(self.model.puzzle_number))
        self.view.update_score_box(self.model.score)
        self.active = True
        self.flasher = Flasher(self.view.clock.box.label,
                               self.master, self.settings)

    def add_callbacks(self):
        self.view.add_menu_callback(self.service_menu)
        self.view.add_toolbar_callback(self.service_toolbar)
        self.view.add_candidate_callback(self.square_event)
        self.view.entry_box.add_callback(self.get_puzzle_number)

    def service_menu(self, *args):
        """
        Respond to menu choices
        One of the three event servicing functions.
        """
        strargs = ''
        for item in args:
            strargs += ' ' + item
        PzlG.logger.logger.info("service_menu " + strargs)
        name = args[0]
        if name == 'hint_mode':
            choice = args[1]
            self.set_hint_mode(choice)
        elif name == 'font_size':
            self.set_font_size()
        elif name == 'set_colour':
            choice = args[1]
            self.set_colour(choice)
        elif name == 'save_settings':
            self.settings.preferences.save()
        elif name == 'about':
            self.view.about()
        elif name == 'help':
            self.view.help()

    def service_toolbar(self, button_index, mouse_index):
        """
        Respond to toolbar clicks
        One of the three event servicing functions.
        """
        PzlG.logger.logger.info("service_toolbar " +
                                str(button_index) +
                                ' ' +
                                str(mouse_index))

        if mouse_index == 2:
            alg_index = self.model.editor.algorithm
            self.view.do_tooltip(button_index, alg_index)
        elif mouse_index == 3:
            if button_index == 1:
                if not self.model.is_finished():
                    if self.model.get_hint_status() != Sgg.APPLY_HINT:
                        if button_index == 1:
                            self.exhaust()
            elif button_index == 0:
                self.model.puzzle_number += 1
                self.start_game(self.model.puzzle_number)
        elif mouse_index == 1:
            if button_index == 0:
                self.start_game(None)
            elif button_index == 1:
                if not self.model.is_finished():
                    self.give_hint()
                    self.screen_shot.snap()
            elif button_index == 2:
                if not self.model.is_finished():
                    self.check_errors()
            elif button_index == 3:
                if not self.model.is_finished():
                    self.show_solution()
                    self.screen_shot.snap()

    def square_event(self, datum_index, candidate, mouse_index):
        """
        Respond to board clicks
        One of the three event servicing functions.
        """
        if self.active:
            PzlG.logger.logger.info("square_event " +
                                    str(mouse_index) +
                                    ' ' +
                                    str(datum_index) +
                                    ' ' +
                                    str(candidate))
            # if necessary abort an active hint
            board_updates = self.model.editor.abort_ops
            self.view.update_squares(board_updates)
            self.model.editor.reset()
            self.model.set_hint_status(Sgg.GIVE_HINT)
            self.model.apply_user_edits(datum_index,
                                        candidate,
                                        mouse_index)

            board_updates = self.model.user_edits.apply_ops
            self.view.update_squares(board_updates)
            self.check_finished()

    def ready_for_hint(self):
        if not self.model.is_finished():
            self.model.editor.reset()
            self.model.set_hint_status(Sgg.GIVE_HINT)
            if self.model.status:
                self.view.hint_off()
                self.view.thumbs_up()
                self.error_detected = False
            # turn on the clock
            self.view.clock.set_show(True)

    def give_hint(self):
        if self.model.get_hint_status() == Sgg.GIVE_HINT:
            self.model.do_hint()
            if not self.model.status:
                self.view.thumbs_down()
                self.colour_errors()
                self.error_detected = True
            elif self.model.progress:
                self.view.hint_on()
                board_updates = self.model.editor.show_ops
                self.view.update_squares(board_updates)
            self.view.update_clock_box(self.model.editor.algorithm)
            self.view.clock.set_show(False)
            self.model.set_hint_status(Sgg.APPLY_HINT)
        else:
            self.model.apply_hint()
            board_updates = self.model.editor.apply_ops
            self.view.update_squares(board_updates)
            self.ready_for_hint()
            self.check_finished()

    def check_errors(self):
        if self.error_detected:
            # may have deleted edits
            if self.model.user_edits.edits:
                # we know there's an error already, so now we correct it
                self.uncolour_errors()
                self.model.user_edits.undo()
                board_updates = self.model.user_edits.undo_ops
                self.view.update_squares(board_updates)
            self.model.user_edits.reset()
            self.model.check_errors()
            if not self.model.status:
                PzlG.logger.logger.error("check_errors ")
                exit(1)
            self.ready_for_hint()
        else:
            self.model.check_errors()
            if not self.model.status:
                self.view.thumbs_down()
                self.error_detected = True
                self.colour_errors()

    def check_finished(self):
        """
        Check if finished.
        If so, handle buttons, flasher and clock
        """
        if self.active:
            if self.model.is_finished():
                # next line added 2017-06-13
                self.show_solution()
                self.screen_shot.snap()
                self.flasher.flash()

    def show_solution(self):
        self.model.show_solution()
        board_updates = self.model.get_board_updates()
        self.view.update_squares(board_updates)
        self.view.hint_off()
        self.view.game_over()
        self.view.clock.set_show(True)
        self.view.clock_stop()
        self.active = False

    def exhaust(self):
        """
        Apply simple_filter_exhaustively
        """
        self.model.exhaust_puzzle()
        # now update the view
        board_updates = self.model.get_board_updates()
        self.view.update_squares(board_updates)
        self.screen_shot.snap()
        self.check_finished()

    def colour_errors(self):
        """
        Colour the error squares
        """
        self.model.get_colour_ops()
        board_updates = self.model.user_edits.show_error_ops
        self.view.update_squares(board_updates)

    def uncolour_errors(self):
        """
        Uncolour coloured squares
        """
        self.model.get_uncolour_ops()
        board_updates = self.model.user_edits.unshow_error_ops
        self.view.update_squares(board_updates)

    def set_hint_mode(self, hint_mode_name):
        if hint_mode_name == 'simplest_hint':
            Sgg.simplest_hint = True
        else:
            Sgg.simplest_hint = False

    def set_font_size(self):
        self.settings.do_scaling()
        puzzle_number = self.model.puzzle_number
        self.start_game(puzzle_number)

    def set_colour(self, choice):
        try:
            bg_colour = self.settings.dic[choice]
            result = tkinter.colorchooser.askcolor(bg_colour)
            if result[1] is not None:
                self.settings.dic[choice] = result[1]
                self.start_game(None)
        except KeyError:
            PzlG.logger.logger.error("set_colour " + choice)

    def get_puzzle_number(self, text):
        if text.find('XYZZY') != -1:
            self.view.xyzzy()
            self.view.entry_box.clear()
        else:
            try:
                puzzle_number = int(text)
                self.start_game(puzzle_number)
                self.screen_shot.snap()
            except ValueError:
                self.view.entry_box.clear()


INITIALISE = PzlG()
if __name__ == '__main__':
    try:
        ROOT = tk.Tk()
        ROOT.withdraw()
        APP = Controller(ROOT)
        ROOT.mainloop()
        PzlG.logger.logger.info("Stop program")
    except:
        PzlG.logger.logger.info("Failure!")
        PzlG.logger.logger.exception("Unhandled exception during main")
