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

# Copyright (C) 2021 Rod Engerstad

from itertools import permutations
from itertools import product
import random
from random import randint
from random import shuffle
import tkinter as tk

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 ViewB
from pzl_lib import Square
from pzl_lib import Egg
from pzl_lib import EditPair
from pzl_lib import UserEdits
from pzl_lib import Editor
from skyscrapers_data import ModelData


class Sgg(object):
    """
    Globals
    """
    grid_width = None
    grid_height = None
    size = None
    GIVE_HINT = 0
    APPLY_HINT = 1
    CAUSE = 0
    EFFECT = 1
    SOLVE = 0
    HINT = 1
    algorithm_counts = [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]


class Settings(SettingsB):
    """
    General configuration and settings
    """
    def __init__(self):
        SettingsB.__init__(self)
        self.original_square_size = 54
        self.square_size = self.original_square_size
        self.dic = {}
        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['font_size'] = '2'
        self.menu_items = []
        self.preferences = Preferences(self.dic)
        self.preferences.load()
        self.load_menus()
        self.count = len(self.menu_items)
        self.set_fonts()
        self.make_help()
        self.make_tooltip()
        self.make_alg_help()

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

    def make_alg_help(self):
        self.alg_text = [_("Alg_0, Exclusion rule: only one of each digit can occur in any strip, so once an answer is set all other occurrences of that digit in the same strip can be deleted."), _("Alg_1: For clue 1 the highest value must be at position 1."), _("Alg_2: For clue 2 the highest value cannot be at position 1."), _("Alg_3: For clue 2 with the lowest value at position 1, the highest value must be at position 2."), _("Alg_4: For clue 2 the second highest cannot be at position 2."), _("Alg_5: For clue size the values must be in ascending order."), _("Alg_6 Hidden single: all strips must contain exactly one of each of the digits; if a cell contains the only remaining copy of a digit in a strip, that digit must be the answer for that cell, and any other digits in that cell can be deleted."), _("Alg_7: For clue 3 the highest and next highest values cannot be at position 1."), _("Alg_8: For clue 3 the highest value cannot be at position 2."), _("Alg_9: For clue 2 with the highest value on the opposite edge, the next highest must be in position 1."), _("Alg_10: For clue size - 1 the highest value must be at the opposite edge or next to it."), _("Alg_11: For clue size - 1 and size > 4, the highest and next highest cannot be in positions 1 and 2 and 3 cannot be in position 1. If size is 4, only the first position is affected."), _("Alg_12 Hidden pairs: all strips must contain exactly one of each of the digits; if a pair of cells contain the only copies of two digits in a strip, those digits must be the answers for the two cells, and any other digits in those cells can be deleted."), _("Alg_13 Naked pairs: all strips must contain exactly one of each of the digits; if a pair of cells each contain only the same two digits those digits must be the answers for the two cells, and any other occurrences of those digits in the corresponding strip can be deleted."), _("Alg_14: When there are limited choices which can satisfy a strip, delete those candidates which are incompatible with any solution.")]

    def make_help(self):
        self.help_text = _("The program starts with a randomly selected puzzle displayed. The 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. The clock ticks every 5 seconds but will be replaced by the algorithm code if a hint is requested. A left click on the hint button will request a hint. While the hint is active (shown by the wand icon leaning leftwards) 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. A further left click will delete the red candidates. The program contains 1500 puzzles: 500 each of sizes 4, 5 and 6, roughly sorted in order of difficulty.")

    def load_menus(self):
        self.menu_items.append(MenuItem(
            _('Font Size'),
            [
                '2',
                '3',
                '5',
                '7',
                '8',
                '9',
                '10',
                '11',
                '12',
                '13'],
            '',
            [
                '2',
                '3',
                '5',
                '7',
                '8',
                '9',
                '10',
                '11',
                '12',
                '13'],
            'font_size'
        ))
        self.menu_items.append(MenuItem(
            _('Save Settings'),
            '',
            '',
            '',
            'save_settings'
        ))
        self.menu_items.append(MenuItem(
            _('Help'),
            '',
            '',
            '',
            'help'
        ))
        self.menu_items.append(MenuItem(
            _('About'),
            '',
            '',
            '',
            'about'
        ))



class SkyLabel(tk.Frame):
    """
    Draw a clue label
    """
    def __init__(self, parent, settings, datum, row, column):
        bg = settings.green
        font = settings.value_font
        width = settings.square_size
        tk.Frame.__init__(self, parent, relief='raised',
                          highlightbackground=settings.black,
                          highlightthickness=1,
                          bd=1, bg=bg)
        self.grid(row=row, column=column)
        self.columnconfigure(0, minsize=width)
        self.rowconfigure(0, minsize=width)
        text = ' '
        if datum.answer != -1:
            text = datum.guess + 1
        label = tk.Label(self, font=font,
                         width=1, height=1, text=text, bg=settings.green)
        label.grid(row=0, column=0)
        self.candidates = []

    def my_update(self, update):
        pass


class Board(tk.Frame):
    """
    Handle board operations
    """
    def __init__(self, parent, settings, grid_dic):
        self.settings = settings
        self.grid_dic = grid_dic
        self.widget_dic = {}
        bg = self.settings.green
        tk.Frame.__init__(self, parent, relief='flat',
                          highlightbackground=self.settings.grey,
                          highlightthickness=2,
                          bd=2, bg=bg)
        bg = 'white'
        for y, x in product(list(range(Sgg.grid_height+2)),
                            list(range(Sgg.grid_width+2))):
            key = (x-1, y-1)
            datum = self.grid_dic[key]
            #if not datum.candidates:
            if not datum.is_active():
                widget = SkyLabel(self, self.settings,
                                  datum, y, x)
            else:
                widget = Square(self, self.settings,
                                datum, datum.colour,
                                key, y, x)
            self.widget_dic[key] = widget
        self.grid(row=0, column=0)

    def my_destroy(self):
        self.destroy()

    def add_candidate_callback(self, func):
        for key, widget in list(self.widget_dic.items()):
            candidates = widget.candidates
            for candidate in candidates:
                candidate.callback = func

    def get_square_colour(self, square_index):
        return self.grid_dic[square_index].colour

    def update_square(self, update):
        """
        set some colours
        """
        operation = update.operation
        cell_id = update.cell_id
        widget = self.widget_dic[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_skyscrapers, 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 update_clock_box(self, algorithm):
        value = '  '
        if algorithm is not None:
            strip = algorithm % (Sgg.size * 4)
            alg = algorithm // (Sgg.size * 4)
            a = str(alg)
            if strip < Sgg.size:
                d = 'D'
            elif strip < 2 * Sgg.size:
                d = 'U'
            elif strip < 3 * Sgg.size:
                d = 'R'
            else:
                d = 'L'
            value = a + d
        self.clock.box.set_text(value)


class Datum(object):
    """
    Handle data for board cells
    """
    def __init__(self, key, answer, size):
        self.key = key
        self.index = key
        self.answer = answer - 1
        self.size = size
        self.colour = 'white'
        self.colour = '#DFDFDF'
        self.guess = None
        candidates = list(range(self.size))
        self.candidates = set(candidates)
        self.active = True
        if key[0] < 0 or key[1] < 0 or\
           key[0] == self.size or key[1] == self.size:
            self.active = False
            self.candidates = set([])
            self.colour = '#82c026'
            if self.answer != -1:
                self.guess = answer - 1

    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 not self.is_active():
            return error
        if self.guess is None:
            if self.answer not in self.candidates:
                error = 1
        else:
            if self.candidates:
                error = 2
            if self.answer != self.guess:
                error += 4
        return error

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

    def is_active(self):
        return self.active

    def set_guess(self, guess):
        self.guess = guess
        self.candidates = set([])

    def unset_guess(self):
        self.guess = None

    def set_answer(self):
        self.guess = self.answer
        self.candidates = set([])

    def del_candidate(self, candidate):
        """
        return guess if deleting last candidate
        """

        ret = -1
        if candidate in self.candidates:
            self.candidates.remove(candidate)
        if self.guess is None:
            if len(self.candidates) == 1:
                candidate = self.candidates.pop()
                self.set_guess(candidate)
                ret = candidate
        return ret

    def add_candidate(self, candidate):
        ret = -1
        self.candidates.add(candidate)
        if self.guess is not None:
            ret = self.guess
            self.candidates.add(ret)
            self.guess = None
        return ret

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

    def is_compatible(self, candidate):
        ret = False
        if self.is_solved():
            if candidate == self.guess:
                ret = True
        else:
            if candidate in self.candidates:
                ret = True
        return ret

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


class Strip(object):
    def __init__(self, datums, clue, size, strip_index):
        self.datums = datums
        self.clue = clue
        self.size = size
        self.strip_index = strip_index


class Model(object):
    """
    Hint algorithms, etc
    """
    def __init__(self, model_data):
        self.model_data = model_data
        self.size = 0
        self.clues_down = []
        self.clues_up = []
        self.clues_right = []
        self.clues_left = []
        self.datum_dic = {}
        self.strips = []
        self.strip = None
        self.hint = []
        self.scores = [1, 1, 1, 1, 1, 1, 2, 2, 2, 2, 2, 3, 3, 3, 10]
        self.hint_status = None
        self.errors = []
        self.perms_dic = {}
        self.algorithm = None
        self.algorithms = [
            (self.alg_0),
            (self.alg_1),
            (self.alg_2),
            (self.alg_3),
            (self.alg_4),
            (self.alg_5),
            (self.alg_6),
            (self.alg_7),
            (self.alg_8),
            (self.alg_9),
            (self.alg_10),
            (self.alg_11),
            (self.alg_12),
            (self.alg_13),
            (self.alg_14),
            ]
        self.zero_algorithm_counts()
        self.user_edits = UserEdits()
        self.editor = Editor(Sgg.HINT)
        self.status = True
        self.difficulty = None

    def zero_algorithm_counts(self):
        self.algorithm_counts = [0 for i in self.algorithms]

    def update_algorithm_counts(self, algorithm, count):
        self.algorithm_counts[algorithm] += count
        Sgg.algorithm_counts[algorithm] += count

    def set_hint_status(self, status):
        self.hint_status = status

    def get_hint_status(self):
        return self.hint_status

    def get_puzzle(self, puzzle_index):
        puzzle = self.model_data.get_puzzle(puzzle_index)
        if puzzle:
            self.make_puzzle(puzzle)
            self.make_perms()
            self.make_strips()

    def check_errors(self):
        self.status = True
        self.errors = []
        for key, datum in list(self.datum_dic.items()):
            status = datum.is_consistent()
            if status != 0:
                self.errors.append(key)
        if self.errors:
            self.status = False

    def show_solution(self):
        updates = []
        for y, x in product(list(range(Sgg.grid_height)),
                            list(range(Sgg.grid_width))):
            key = (x, y)
            datum = self.datum_dic[key]
            datum.set_answer()
            updates.append([key, [1, [datum.guess]]])
        return updates

    def is_finished(self):
        ret = True
        for key, datum in list(self.datum_dic.items()):
            if not datum.is_solved():
                ret = False
                break
        return ret

    def make_puzzle(self, puzzle):
        """
        The puzzle is stored: size, 4 x clues (d,u,r,l), rows.
        To enable drawing the grid and clue rows and columns
        using existing functions we create extra datums so that
        the clues have negative and size keys, and the grid
        has the usual 0,0 to size-1, size-1.
        """
        self.difficulty = puzzle[0]
        self.size = puzzle[1]
        Sgg.size = self.size
        Sgg.grid_width = self.size
        Sgg.grid_height = self.size
        self.clues_down = []
        self.clues_up = []
        self.clues_right = []
        self.clues_left = []
        self.datum_dic = {}
        # dummy corner Datums
        self.datum_dic[-1, -1] = Datum((-1, -1), 0, self.size)
        self.datum_dic[self.size, -1] = Datum((self.size, -1), 0, self.size)
        self.datum_dic[-1, self.size] = Datum((-1, self.size), 0, self.size)
        self.datum_dic[self.size, self.size] = Datum((self.size, self.size),
                                                     0, self.size)

        item = 1
        for x in range(self.size):
            item += 1
            self.clues_down.append(puzzle[item])
            key = (x, -1)
            self.datum_dic[x, -1] = Datum((x, -1), puzzle[item], self.size)
        for x in range(self.size):
            item += 1
            key = (x, self.size)
            self.clues_up.append(puzzle[item])
            self.datum_dic[x, self.size] = Datum((x, self.size),
                                                 puzzle[item], self.size)
        for x in range(self.size):
            item += 1
            key = (-1, x)
            self.clues_right.append(puzzle[item])
            self.datum_dic[-1, x] = Datum((-1, x), puzzle[item], self.size)
        for x in range(self.size):
            item += 1
            key = (self.size, x)
            self.clues_left.append(puzzle[item])
            self.datum_dic[self.size, x] = Datum((self.size, x),
                                                 puzzle[item], self.size)
        for y in range(self.size):
            for x in range(self.size):
                item += 1
                self.datum_dic[x, y] = Datum((x, y), puzzle[item], self.size)

    def make_strips(self):
        """
        size Strips per direction: down, up, right, left
        """
        self.strips = []
        strip_index = -1
        # down
        for x in range(self.size):
            strip_index += 1
            clue = self.clues_down[x]
            datums = []
            for y in range(self.size):
                datums.append(self.datum_dic[x, y])
            self.strips.append(Strip(datums, clue, self.size, strip_index))

        # up
        for x in range(self.size):
            strip_index += 1
            clue = self.clues_up[x]
            datums = []
            for y in range(self.size - 1, -1, -1):
                datums.append(self.datum_dic[x, y])
            self.strips.append(Strip(datums, clue, self.size, strip_index))

        # right
        for y in range(self.size):
            strip_index += 1
            clue = self.clues_right[y]
            datums = []
            for x in range(self.size):
                datums.append(self.datum_dic[x, y])
            self.strips.append(Strip(datums, clue, self.size, strip_index))

        # left
        for y in range(self.size):
            strip_index += 1
            clue = self.clues_left[y]
            datums = []
            for x in range(self.size - 1, -1, -1):
                datums.append(self.datum_dic[x, y])
            self.strips.append(Strip(datums, clue, self.size, strip_index))

    def alg_0(self):
        """
        Each answer can only occur once per strip
        score 1
        """
        datums = self.strip.datums
        pairs = []
        for datum_0 in datums:
            if datum_0.is_solved():
                for datum_1 in datums:
                    if datum_1 != datum_0:
                        if not datum_1.is_solved():
                            if datum_0.guess in datum_1.candidates:
                                pair = [datum_1.key, [datum_0.guess]]
                                pairs.append(pair)
        if pairs:
            self.hint = [self.strip.strip_index, pairs]

    def alg_1(self):
        """
        If the clue is 1, then the highest must be in position 0
        score 1
        """
        strip = self.strip
        datums = strip.datums
        clue = strip.clue
        if clue == 1 and datums[0].candidates:
            cands = list(datums[0].candidates)
            key = datums[0].key
            cands.remove(strip.size - 1)
            self.hint = [strip.strip_index, [[key, cands]]]

    def alg_2(self):
        """
        If the clue is 2, then the highest cannot be in position 0
        score 1
        """
        strip = self.strip
        datums = strip.datums
        clue = strip.clue
        if clue == 2 and self.size - 1 in datums[0].candidates:
            cands = [self.size - 1]
            key = datums[0].key
            self.hint = [strip.strip_index, [[key, cands]]]

    def alg_3(self):
        """
        If the clue is 2 and the lowest is at position 0, then the
        highest must be in position 1
        score 1
        """
        strip = self.strip
        datums = strip.datums
        clue = strip.clue
        cands = []
        keys = []
        if clue == 2 and datums[0].guess == 0:
            for cand in datums[1].candidates:
                if cand != self.size - 1:
                    cands.append(cand)
                    keys = [datums[1].key]

            if cands:
                self.hint = [strip.strip_index, [[datums[1].key, cands]]]

    def alg_4(self):
        """
        If the clue is 2 then the second highest cannot be at position 1
        score 1
        """
        strip = self.strip
        datums = strip.datums
        clue = strip.clue
        cands = []
        keys = []
        if clue == 2 and datums[1].candidates:
            if self.size - 2 in datums[1].candidates:
                cands.append(self.size - 2)
                keys = [datums[1].key]

            if cands:
                self.hint = [strip.strip_index, [[datums[1].key, cands]]]

    def alg_5(self):
        """
        If the clue is size, then the answers must be in ascending order
        score 1
        """
        strip = self.strip
        datums = strip.datums
        clue = strip.clue
        cands = []
        keys = []
        got_one = False
        pairs = [strip.strip_index, []]
        if clue == self.size:
            for i in range(self.size):
                cands = []
                for cand in datums[i].candidates:
                    if cand != i:
                        cands.append(cand)
                if cands:
                    pairs[1].append([datums[i].key, cands])
                    got_one = True
            if got_one:
                self.hint = pairs

    def alg_6(self):
        """
        Hidden single
        score 2
        """
        datums = self.strip.datums
        pairs = []
        for datum_0 in datums:
            candidates = datum_0.candidates
            strip_candidates = set()
            for datum_1 in datums:
                if datum_1 != datum_0:
                    strip_candidates = strip_candidates.union(
                        datum_1.candidates)
            unique = candidates.difference(strip_candidates)
            if len(unique) == 1:
                candidate = unique.pop()
                effect_candidates = list(datum_0.other_candidates([candidate]))
                pair = [datum_0.key, effect_candidates]
                pairs.append(pair)
                break
        if pairs:
            self.hint = [self.strip.strip_index, pairs]

    def alg_7(self):
        """
        If the clue is 3, then the highest and next highest cannot
        be in position 0.
        score 2
        """
        strip = self.strip
        datums = strip.datums
        clue = strip.clue
        cands = []
        if clue == 3:
            if self.size - 1 in datums[0].candidates:
                cands.append(self.size - 1)
            if self.size - 2 in datums[0].candidates:
                cands.append(self.size - 2)

            if cands:
                self.hint = [strip.strip_index, [[datums[0].key, cands]]]

    def alg_8(self):
        """
        If the clue is 3, then the highest cannot be in position 2
        score 2
        """
        strip = self.strip
        datums = strip.datums
        clue = strip.clue
        cands = []
        if clue == 3:
            if self.size - 1 in datums[1].candidates:
                cands.append(self.size - 1)

            if cands:
                self.hint = [strip.strip_index, [[datums[1].key, cands]]]

    def alg_9(self):
        """
        If the clue is 2 and the highest is on the opposite edge, then the
        next highest must be in position 0
        score 2
        """
        strip = self.strip
        datums = strip.datums
        clue = strip.clue
        cands = []
        keys = []
        if clue == 2 and datums[self.size - 1].guess == self.size - 1:
            for cand in datums[0].candidates:
                if cand != self.size - 2:
                    cands.append(cand)
                    keys = [datums[0].key]

            if cands:
                self.hint = [strip.strip_index, [[datums[0].key, cands]]]

    def alg_10(self):
        """
        If the clue is size - 1, then the highest must be
        distal or next to distal. Which means it cannot be
        in the other cells.
        score 2
        """
        strip = self.strip
        datums = strip.datums
        clue = strip.clue
        cands = []
        keys = set([])
        got_one = False
        pairs = [[strip.strip_index], []]
        if clue == self.size - 1:
            for i in range(self.size - 2):
                if self.size - 1 in datums[i].candidates:
                    pair = [datums[i].key, [self.size - 1]]
                    pairs[1].append(pair)
                    got_one = True
                    cands.append(self.size - 1)
                    keys.add(datums[i].key)

            if got_one:
                self.hint = pairs

    def alg_11(self):
        """
        If the clue is size - 1, and size > 4 then the highest and next
        highest cannot be in positions 0 and 1
        Neither can 3
        if size == 4, only first position is affected
        score 3
        """

        strip = self.strip
        datums = strip.datums
        clue = strip.clue
        pair_0 = []
        pair_1 = []
        cands_0 = []
        cands_1 = []
        if clue == self.size - 1:
            if self.size - 1 in datums[0].candidates:
                cands_0.append(self.size - 1)
            if 2 in datums[0].candidates:
                cands_0.append(2)
            if self.size > 4 and self.size - 2 in datums[0].candidates:
                cands_0.append(self.size - 2)
            if cands_0:
                pair_0 = [datums[0].key, cands_0]
            if self.size - 1 in datums[1].candidates:
                cands_1.append(self.size - 1)
            if self.size > 4 and self.size - 2 in datums[1].candidates:
                cands_1.append(self.size - 2)
            if cands_1:
                pair_1 = [datums[1].key, cands_1]

            if cands_0 or cands_1:
                self.hint = [strip.strip_index, []]
                if pair_0:
                    self.hint[1].append(pair_0)
                if pair_1:
                    self.hint[1].append(pair_1)

    def alg_12(self):
        """
        Find pairs of cells, each with the same pair of candidates
        which do not occur in any others in their strip
        """
        pairs = []
        datums = self.strip.datums
        for datum_0, datum_1 in product(datums, datums):
            if datum_1 != datum_0:
                c_0 = datum_0.candidates
                c_1 = datum_1.candidates
                if c_0 and c_1:
                    c_union = c_0.union(c_1)
                    c_other = set([])
                    for datum_2 in datums:
                        if datum_2 != datum_0 and datum_2 != datum_1:
                            c_2 = datum_2.candidates
                            c_other = c_other.union(c_2)
                    diff = c_union.difference(c_other)
                    if c_other and len(diff) == 2:
                        # there's 2 of them, so they are hidden pairs
                        # and all other candidates can be removed from
                        # their cells
                        e_cands = list(datum_0.other_candidates(diff))
                        if e_cands:
                            pair = [datum_0.key, e_cands]
                            pairs.append(pair)
                        e_cands = list(datum_1.other_candidates(diff))
                        if e_cands:
                            pair = [datum_1.key, e_cands]
                            pairs.append(pair)
                    if pairs:
                        break
        if pairs:
            self.hint = [self.strip.strip_index, pairs]

    def alg_13(self):
        """
        Find pairs of cells, each with the same pair of candidates
        and no others. Those candidates must be the solutions for those
        two cells, so cannot occur anywhere else in the same strip.
        """
        pairs = []
        datums = self.strip.datums
        for datum_0, datum_1 in product(datums, datums):
            if datum_1 != datum_0:
                c_0 = datum_0.candidates
                c_1 = datum_1.candidates
                if c_0 and c_1:
                    c_union = c_0.union(c_1)
                    if len(c_union) == 2:
                        candidates = list(c_union)
                        for datum in datums:
                            if datum != datum_0 and datum != datum_1:
                                e_cands = c_union.intersection(datum.candidates)
                                if e_cands:
                                    pair = [datum.key, e_cands]
                                    pairs.append(pair)
            if pairs:
                break
        if pairs:
            self.hint = [self.strip.strip_index, pairs]

    def alg_14(self):
        """
        Limited perms: once we've reduced the numbers of candidates
        there are limited possible permutations which can be chosen
        and these may in turn limit the candidates in some cells.
        score 10
        """
        max_good = 4
        strip = self.strip
        strip_index = strip.strip_index
        clue = strip.clue
        if clue != 0:
            perms = self.perms_dic[strip.clue]
            datums = strip.datums
            good = []
            # get perms which are compatible with remaining candidates or guess
            for perm in perms:
                bad = False
                for pos, value in enumerate(perm):
                    if not datums[pos].is_compatible(value - 1):
                        bad = True
                        break
                if not bad:
                    good.append(perm)

            if len(good) <= max_good:
                conserved = [set([]) for x in range(self.size)]
                for perm in good:
                    for pos in range(self.size):
                        conserved[pos].add(perm[pos] - 1)

                        # conserved are the only possible candidates
                        # in each position

                for datum_index, datum in enumerate(datums):
                    candidates = []
                    for candidate in datum.candidates:
                        if candidate not in conserved[datum_index]:
                            candidates.append(candidate)
                    if candidates:
                        pairs = [[datum.key, candidates]]
                        self.hint = [strip.strip_index, pairs]
                        return

    def apply_user_edits(self, datum_index, candidate, mouse_index):
        datum = self.datum_dic[datum_index]
        candidates = [candidate]
        if candidate is not None:
            # always start a new apply_ops
            self.user_edits.apply_ops = []
            if mouse_index == 1:
                apply_op = Egg.DELETE_CANDIDATES
                # if last but 1 candidate, this is also a set guess
                # so we need to know which candidates to delete
                if len(datum.candidates) == 2:
                    # its a set_guess too
                    candidates = list(datum.candidates)
                edit_pair = EditPair(datum, candidates)
                # do the edit
                guess = datum.del_candidate(candidate)
                if self.user_edits.edits or datum.is_consistent() != 0:
                    self.user_edits.add([edit_pair, apply_op])
                self.user_edits.add_op([edit_pair, apply_op])
                if guess != -1:
                    apply_op = Egg.SET_GUESS
                    edit_pair = EditPair(datum, [guess])
                    if self.user_edits.edits or datum.is_consistent() != 0:
                        self.user_edits.add([edit_pair, apply_op])
                    self.user_edits.add_op([edit_pair, apply_op])
            elif mouse_index == 2:
                # changed order of next lines 2017-06-13
                # so edit_pair includes guess
                guess = datum.add_candidate(candidate)
                apply_op = Egg.ADD_CANDIDATES
                if guess != -1 and guess != candidate:
                    candidates.append(guess)
                edit_pair = EditPair(datum, candidates)
                # do the edit
                if self.user_edits.edits or datum.is_consistent() != 0:
                    self.user_edits.add([edit_pair, apply_op])
                self.user_edits.add_op([edit_pair, apply_op])
            elif mouse_index == 3:
                if datum.candidate_is_set(candidate):
                    # for display we need to delete the candidates first
                    del_candidates = list(datum.candidates)
                    apply_op = Egg.DELETE_CANDIDATES
                    edit_pair = EditPair(datum, del_candidates)
                    self.user_edits.add_op([edit_pair, apply_op])
                    # do the edit
                    datum.set_guess(candidate)
                    apply_op = Egg.SET_GUESS
                    edit_pair = EditPair(datum, [candidate])
                    if self.user_edits.edits or datum.is_consistent() != 0:
                        apply_op = Egg.DELETE_CANDIDATES
                        edit_pair = EditPair(datum, del_candidates)
                        self.user_edits.add([edit_pair, apply_op])
                        apply_op = Egg.SET_GUESS
                        edit_pair = EditPair(datum, [candidate])
                        self.user_edits.add([edit_pair, apply_op])
                    self.user_edits.add_op([edit_pair, apply_op])

    def manage_edits(self, algorithm):
        """
        This is a hangover from the sudoku code.
        """
        if self.editor.mode == Sgg.SOLVE:
            self.editor.apply()
            count = len(self.editor.changed_datum)

            if count > 0:
                self.update_algorithm_counts(algorithm, count)
            self.editor.reset()

    def apply_algorithms(self):
        """
        Try all algorithms. If one succeeds, return.
        """
        cause_pairs = []
        effect_pairs = []
        self.hint = []
        for self.algorithm, algorithm in enumerate(self.algorithms):
            for strip_index, self.strip in enumerate(self.strips):
                algorithm()
                if self.hint:
                    effect_pairs = []
                    for pair in self.hint[1]:
                        key = pair[0]
                        datum = self.datum_dic[key]
                        candidates = pair[1]
                        effect_pair = EditPair(datum, candidates)
                        effect_pairs.append(effect_pair)
                        alg_index = self.algorithm * 4 * self.size
                        alg_index += strip_index
                        self.editor.add(cause_pairs,
                                        effect_pairs,
                                        alg_index)

                    if self.editor.got_data():
                        self.manage_edits(self.algorithm)
                        break
            if self.hint:
                break

    def get_colour_ops(self):
        self.user_edits.get_colour_ops(self.errors)

    def get_uncolour_ops(self):
        self.user_edits.get_uncolour_ops(self.errors)

    def get_board_updates(self):
        for key, datum in list(self.datum_dic.items()):
            if datum.is_active():
                self.editor.get_update_ops(datum)
        return self.editor.update_ops

    def apply_hint(self):
        if self.editor.got_data():
            for edit_pair in self.editor.effects:
                datum_index = edit_pair.datum.key
                candidates = edit_pair.candidates
                datum = self.datum_dic[datum_index]
                for candidate in candidates:
                    datum.del_candidate(candidate)
                    self.algorithm_counts[self.algorithm] += 1

    def solve_puzzle(self):
        self.editor.mode = Sgg.SOLVE
        self.editor.guess_set = True
        self.zero_algorithm_counts()
        progress = True
        while progress and not self.is_finished():
            progress = False
            self.apply_algorithms()
            if self.hint:
                progress = True
                self.apply_hint()
                self.check_errors()
                if not self.status:
                    # print 'error', self.algorithm
                    break

    def get_difficulty(self):
        score = 0
        for x, y in enumerate(self.scores):
            score += y * self.algorithm_counts[x]
        self.difficulty = score

    def do_hint(self):
        self.editor.set_mode(Sgg.HINT)
        self.apply_algorithms()

    def make_perms(self):
        values = list(range(1, self.size + 1))
        perms = permutations(values)
        self.perms_dic = {}

        for perm in list(perms):
            self.perms_dic.setdefault(0, []).append(perm)
            top = perm[0]
            inview = 1
            for i in perm[1:]:
                if i > top:
                    inview += 1
                top = max(top, i)
            self.perms_dic.setdefault(inview, []).append(perm)
        """
        for key, item in list(self.perms_dic.items()):
            print key
            for i in item:
                print i
        """

    def generate(self):
        """
        Generate puzzles and write them to a file
        """
        seed = 1
        seed = 0
        random.seed(seed)
        puzzles = []
        max_puzzles = 500
        min_size = 4
        max_size = 6
        for self.size in range(min_size, max_size + 1):
            Sgg.size = self.size
            self.generate_1(puzzles, max_puzzles)
        Model.save_puzzles(puzzles)
        exit(1)

    def generate_1(self, puzzles, max_puzzles):
        puzzle_count = 0
        while puzzle_count < max_puzzles:
            while True:
                puzzle_in = Model.gmake_puzzle(self.size)
                if self.try_solved(puzzle_in):
                    # have a puzzle which can be solved with all clues
                    puzzle = list(puzzle_in)
                    clue_indexes = list(range(2, 2 + 4 * self.size))
                    shuffle(clue_indexes)
                    removed = []
                    best = []
                    # try removing clues
                    best = self.get_best(puzzle_in, removed,
                                         clue_indexes, best)
                    puzzle = list(puzzle_in)
                    for clue_index in best:
                        puzzle[clue_index] = 0
                    if self.try_solved(puzzle):
                        pass
                    self.get_difficulty()
                    puzzle[0] = self.difficulty
                    puzzles.append(puzzle)
                    puzzle_count += 1
                    # print '################################', puzzle_count
                    if puzzle_count == max_puzzles:
                        break

    def get_best(self, puzzle_in, removed, clue_indexes, best):
        """
        puzzle_in is solvable with all clues
        """

        if clue_indexes:
            puzzle = list(puzzle_in)
            clue_index = clue_indexes.pop()
            removed.append(clue_index)
            # zero removed clues
            for clue_index in removed:
                puzzle[clue_index] = 0
            if self.try_solved(puzzle):
                if len(removed) > len(best):
                    best = list(removed)
                best = self.get_best(puzzle_in, removed, clue_indexes, best)
            else:
                removed.pop()
                best = self.get_best(puzzle_in, removed, clue_indexes, best)
        return best

    def try_solved(self, puzzle):
        ret = False
        self.make_puzzle(puzzle)
        self.make_perms()
        self.make_strips()
        self.solve_puzzle()
        if self.is_finished():
            ret = True
        return ret

    @staticmethod
    def save_puzzles(puzzles):
        puzzles = sorted(puzzles)
        filename = 'wapuzzles.txt'
        with open(filename, "w") as fo:
            rec = ''
            for puzzle in puzzles:
                for item in puzzle:
                    rec += str(item)
                    rec += ','
            fo.write(rec)

    @staticmethod
    def make_one(size):
        """
        Make a grid
        """
        while True:
            syms_down = [[] for x in range(size)]
            rows = []
            give_up = False
            for y in range(size):
                syms_across = []
                pool = list(range(size))
                shuffle(pool)
                row = []
                x = 0
                while x < size:
                    sym = pool.pop()
                    if sym not in syms_across and sym not in syms_down[x]:
                        row.append(sym)
                        syms_down[x].append(sym)
                        syms_across.append(sym)
                        x += 1
                    else:
                        used = set(syms_down[x] + syms_across)
                        if len(used) == size:
                            # all digits already used in this row and column
                            give_up = True
                            break
                        pool = list(range(size))
                        shuffle(pool)
                if give_up:
                    break
                rows.append(row)
            if len(rows) == size:
                break

        for row in rows:
            for x in range(size):
                row[x] += 1
        return rows

    @staticmethod
    def gmake_puzzle(size):
        puzzle = [99, size]
        rows = Model.make_one(size)
        clues = Model.count_visible(rows)
        clues = Model.get_all_clues(rows)
        for clue_strip in clues:
            for clue in clue_strip:
                puzzle.append(clue)
        for row in rows:
            for value in row:
                puzzle.append(value)
        return puzzle

    @staticmethod
    def count_visible(rows):
        size = len(rows)
        vis = []
        for y, row in enumerate(rows):
            top = row[0]
            inview = 1
            for x in row:
                if x > top:
                    inview += 1
                top = max(top, x)
            vis.append(inview)
        vis_right = vis
        vis = []
        for y, row in enumerate(rows):
            top = row[size - 1]
            inview = 1
            for x in reversed(row):
                if x > top:
                    inview += 1
                top = max(top, x)
            vis.append(inview)
        vis_left = vis
        return vis_right, vis_left

    @staticmethod
    def get_all_clues(rows):
        """
        Count the values visible along all rows and columns
        to use as potential clues.
        """
        size = len(rows)
        all_clues = []
        tmp = []
        for x in range(size):
            col = []
            for y in range(size):
                col.append(rows[y][x])
            tmp.append(col)
        right, left = Model.count_visible(tmp)
        all_clues.append(right)
        all_clues.append(left)
        right, left = Model.count_visible(rows)
        all_clues.append(right)
        all_clues.append(left)
        return all_clues


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

    def __init__(self, root):
        self.settings = Settings()
        self.master = root
        self.flasher = None
        self.model_data = ModelData()
        self.model = None
        self.user_edits = None
        self.view = View(root, self.settings)
        self.error_detected = False
        self.puzzle_number = None
        puzzle_number = None
        # if len(sys.argv) > 1:
        #    puzzle_number = int(sys.argv[1])
        self.active = False
        self.coloured_squares = []
        self.start_game(puzzle_number)

    def set_puzzle_number(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

    def test(self):
        done = 0
        bad = []
        for puzzle_number in range(self.model_data.puzzle_count):
            # print puzzle_number
            self.model = Model(self.model_data)
            self.model.get_puzzle(puzzle_number)
            self.model.solve_puzzle()
            if not self.model.is_finished():
                bad.append(puzzle_number)
                self.model.is_finished()
            done += 1
        # print 'done', done, 'bad', bad, len(bad)
        # print Sgg.algorithm_counts
        exit(1)

    def start_game(self, puzzle_number):
        # self.test()
        self.model = Model(self.model_data)
        # self.model.generate()
        self.set_puzzle_number(puzzle_number)
        self.view.clock_init()
        self.view.clock_restart()
        self.view.game_over()
        self.model.get_puzzle(self.puzzle_number)
        self.ready_for_hint()
        self.view.thumbs_up()
        self.view.datum = self.model.datum_dic
        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.puzzle_number))
        self.view.update_score_box(self.model.difficulty)
        self.model.set_hint_status(Sgg.GIVE_HINT)
        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
        """

        strargs = ''
        for item in args:
            strargs += ' ' + item
        PzlG.logger.logger.info("service_menu " + strargs)
        name = args[0]
        if name == 'font_size':
            self.set_font_size()
        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):
        PzlG.logger.logger.info("service_toolbar " +
                                str(button_index) +
                                ' ' +
                                str(mouse_index))
        if mouse_index == 2:
            alg_index = None
            if self.model.hint:
                alg_index = self.model.editor.algorithm
                # need to convert strip/algorithm code
                alg_index = alg_index // (Sgg.size * 4)
            self.view.do_tooltip(button_index, alg_index)
        elif mouse_index == 3:
            if button_index == 0:
                self.puzzle_number += 1
                self.start_game(self.puzzle_number)
        elif mouse_index == 1:
            if button_index == 0:
                self.start_game(None)
            elif button_index == 1:
                self.check_errors()
                if self.model.status and not self.model.is_finished():
                    self.give_hint()
            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()

    def square_event(self, datum_index, candidate, mouse_index):
        """
        Respond to board clicks
        One of the three event servicing functions.
        Mouse index 1 == DELETE
                    2 == REWRITE
                    3 == SET GUESS
        candidate numbers at displayed values - 1
        """

        if self.active:
            PzlG.logger.logger.info("square_event " +
                                    str(mouse_index) +
                                    ' ' +
                                    str(datum_index) +
                                    ' ' +
                                    str(candidate))
            # if necessary abort an active hint
            self.view.hint_off()
            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
            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.editor.got_data():
                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():
                self.show_solution()
                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 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_font_size(self):
        self.settings.do_scaling()
        self.start_game(self.puzzle_number)

    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)
            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")
