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

# Copyright (C) 2017 Rod Engerstad

import tkinter as tk
from random import shuffle
from random import randint
import time

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 ColourRamp
from pzl_lib import ViewB
from pzl_lib import Menu
from pzl_lib import Toolbar
from pzl_lib import ClockBox
from wordladder_data import ModelData


class Settings(SettingsB):

    def __init__(self):
        SettingsB.__init__(self)
        self.dic = {}
        self.dic['font_size'] = '1'
        self.dic['rungs'] = '6'
        self.dic['word_length'] = '4'
        self.dic['maximum_wait'] = '2'
        self.list_box_width = 10
        self.original_entry_box_width = 8
        self.entry_box_width = 8
        self.menu_items = []
        self.preferences = Preferences(self.dic)
        self.preferences.load()
        self.rungs = int(self.dic['rungs'])
        self.load_menus()
        self.set_fonts()
        self.count = len(self.menu_items)
        self.make_help()
        self.make_tooltip()

    def make_tooltip(self):
        self.tooltip_text = [
            _('New game'),
            _('Show solution'),
            ]

    def make_help(self):
        self.help_text = _("A word ladder is a column of words in which each word differs from its immediate neighbours by a single letter. Usually players are given the first and last words and have to deduce the intervening words to complete the ladder. The program starts by displaying an empty ladder. There are three ways to start a game. First, if you click the icon to the right of the jigsaw symbol the program will choose a ladder and show the start and end words. Second, if you type an acceptable word (see below) into the top rung (followed by 'Enter') the program will choose a corresponding ladder and display the last word. For both of these game types all rungs will be used. Third, to find the path between two words type the end word into the bottom rung. If there is a ladder less than or equal to the current number of rungs the program will find it. If the path length is less than the number of rungs the program will move the end word to the appropriate rung. If the program does not display an entered word the word was not accepted. If you do not like the last word, type 'Enter' in the top rung and the program will look for an alternative. To solve the puzzle proceed to fill in words which differ from their immediate neighbour(s) by exactly one letter. Words can only be entered on rungs adjacent to filled rungs. If you need help, clicking on the + symbol next to a filled in word will produce a list of all the words to which it differs by a single letter. Clicking on the sad smiley icon will produce a solution (there may be more than one). Using the menu players can choose word lengths of  3, 4 or 5 letters and the number of rungs. The program uses 486 three letter words, 1865 four letter words and 2723 five letter words. This is a large proportion of all words of those lengths. Obvious some words remain aloof by having no words that differ from them by one letter, and others have very few such neighbours. Such words have been omitted. Please note that the program calculates the ladders and this can take a noticeable time, especially when the player enters the start word.")

    def load_menus(self):
        self.menu_items.append(MenuItem(
            _('Word length'),
            ['3',
             '4',
             '5'],
            '',
            ['3',
             '4',
             '5'],
            'word_length'
        ))
        self.menu_items.append(MenuItem(
            _('Rungs'),
            ['3',
             '4',
             '5',
             '6',
             '7',
             '8',
             '9',
             '10',
             '11',
             '12'],
            '',
            ['3',
             '4',
             '5',
             '6',
             '7',
             '8',
             '9',
             '10',
             '11',
             '12'],
            'rungs'
        ))
        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(
            _('Maximum Wait'),
            ['1', '2', '4', '6', '8', '10', '12', '14', '16'],
            '',
            ['1', '2', '4', '6', '8', '10', '12', '14', '16'],
            'maximum_wait'
        ))
        self.menu_items.append(MenuItem(
            _('Save Settings'),
            '',
            '',
            '',
            'save_settings'
        ))
        self.menu_items.append(MenuItem(
            _('Help'),
            '',
            '',
            '',
            'help'
        ))
        self.menu_items.append(MenuItem(
            _('About'),
            '',
            '',
            '',
            'about'
        ))


class Popup(object):
    def __init__(self, settings, frame, rung_index):
        self.settings = settings
        self.frame = frame
        self.rung_index = rung_index
        self.items = []
        button = tk.Menubutton(
            self.frame,
            direction='right',
            bd=0,
            highlightthickness=0,
            padx=0,
            pady=0,
            height=0,
            bg='white',
            font=self.settings.counter_font,
            text='+')
        button.grid(row=0, column=0)
        bg = 'white'
        self.menu = tk.Menu(button, tearoff=0, bg=bg,
                            postcommand=self.popup)
        button['menu'] = self.menu

    def popup(self):
        for item in self.items:
            text = str(item)
            self.menu.add_command(label=text)


class EntryPlus(object):
    def __init__(self, settings, frame, box_index):
        self.settings = settings
        self.box_index = box_index
        self.frame = frame
        self.callback = None
        width = self.settings.entry_box_width
        width = int(self.settings.dic['word_length']) + 2
        font = self.settings.value_font
        highthick = 0
        bg = 'white'
        fg = 'black'
        ebox = tk.Entry(frame, width=width,
                        highlightthickness=highthick, bd=0,
                        insertbackground=fg,
                        font=font,
                        fg=fg, bg=bg)
        ebox.bind('<Return>', self.fetch)
        ebox.grid(row=0, column=0)
        self.box = ebox

    def fetch(self, event):
        text = self.box.get()
        self.callback(text, self.box_index)

    def add_callback(self, func):
        self.callback = func

    def write_box(self, text):
        self.box.insert('end', text)
        self.box.update_idletasks()

    def clear(self):
        self.box.delete(0, last='end')
        self.box.update_idletasks()

    def delete_letter(self):
        word = self.box.get()
        letter_index = len(word) - 1
        self.box.delete(letter_index)
        self.box.update_idletasks()


class MultipleEntry2(object):
    """
    Create and manage multiple EntryBoxes
    """
    def __init__(self, settings, frames):
        self.callback = None
        self.settings = settings
        self.box = []
        for box_index in range(len(frames)):
            frame = frames[box_index]
            ebox = EntryPlus(self.settings, frame, box_index)
            self.box.append(ebox)

    def add_callback(self, func):
        for box in self.box:
            box.add_callback(func)

    def write_box(self, text, box_index):
        self.box[box_index].write_box(text)

    def clear(self, box_index):
        self.box[box_index].clear()

    def delete_letter(self, box_index):
        self.box[box_index].delete_letter()


class Board(tk.Frame):
    def __init__(self, parent, settings):
        """
        """

        self.parent = parent
        self.settings = settings
        bg = self.settings.green
        tk.Frame.__init__(self, parent, relief='flat',
                          highlightthickness=0,
                          bd=0, bg=bg)
        self.board = tk.Frame(self, bg=self.settings.green, bd=2)
        self.rectangle = tk.Frame(self.board, relief='flat',
                                  bd=2, bg=self.settings.grey)
        self.rectangle.grid()
        self.text_frame = tk.Frame(self.rectangle, relief='flat',
                                   bd=2, bg=self.settings.green)
        self.text_frame.grid(row=0, column=0)
        entry_frames = []
        rungs = self.settings.rungs
        for row in range(rungs):
            entry_frame = tk.Frame(self.text_frame, relief='flat',
                                   bd=2, bg=self.settings.green)
            entry_frame.grid(row=row, column=0)
            entry_frames.append(entry_frame)
        self.entry_boxes = MultipleEntry2(self.settings, entry_frames)
        hint_frames = []
        self.popups = []
        for row in range(rungs):
            hint_frame = tk.Frame(self.text_frame, relief='flat',
                                  bd=2, bg=self.settings.green)
            hint_frame.grid(row=row, column=1)
            hint_frames.append(hint_frame)
            popup = Popup(self.settings, hint_frame, row)
            self.popups.append(popup)
        self.board.grid(row=0, column=0)
        self.grid(row=0, column=0)

    def my_destroy(self):
        self.destroy()


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

    def __init__(self, master, settings):
        ViewB.__init__(self, master, settings)
        self.image_dic = {}
        self.make_image_dic()
        self.do_layout(self)
        self.model = None
        self.colour_ramp = ColourRamp(None)
        self.board = None
        self.entry_boxes = None
        self.list_box_width = None
        self.original_entry_box_width = None
        self.original_list_box_width = None

    def start_game(self, redraw):
        if redraw or self.board is None:
            self.make_board()
        self.game_on()

    def make_image_dic(self):
        """
        image, button_index, start
        """
        dic = {}
        dic['start'] = [self.images.start_wordladder, 0, True]
        dic['show_solution'] = [self.images.smiley, 1, True]
        dic['length'] = [None, 2, False]
        self.image_dic = dic

    def do_layout(self, root):
        bg = self.settings.green
        self.outer_frame = tk.Frame(root, bg=bg)
        self.outer_frame.grid()
        bg = self.settings.green
        self.control_frame = tk.Frame(self.outer_frame,
                                      bd=2, bg=bg, relief='raised')
        self.control_frame.grid(row=0, column=0, sticky='EW')
        self.menu_bar = tk.Frame(self.control_frame, relief='raised',
                                 bd=1, bg=bg)
        self.menu_bar.grid(row=0, column=0, sticky='W')
        self.menu = Menu(self.settings, self.menu_bar, self.images.file)
        self.toolbar = Toolbar(self.settings, self.image_dic,
                               self.control_frame)
        self.toolbar.grid(row=0, column=1, sticky='W')
        self.number_frame = tk.Frame(self.control_frame, relief='raised',
                                     bg=bg)
        self.number_frame.grid(row=0, column=2, sticky='NS')
        self.clock_box = ClockBox(self, self.number_frame)
        self.clock_box.grid(row=0, column=2, sticky='NSEW')
        self.top_frame = tk.Frame(self.outer_frame, relief='flat',
                                  bd=1, bg=bg)
        self.top_frame.grid(row=1, column=0)
        self.board = None

    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.entry_boxes = self.board.entry_boxes


class Node(object):
    """
    A node for Djkstra
    """
    def __init__(self, datum, oob):
        self.key = datum.key
        self.neighbours = []
        for neighbour in datum.neighbours:
            if neighbour is not None and neighbour not in oob:
                self.neighbours.append(neighbour)
        self.distance = 999999
        self.unvisited = True
        self.predecessor = None


class Dijkstra(object):
    """
    Dijkstra algorithm to find path from start to finish
    avoiding oob (out of bounds)
    """
    def __init__(self, datum_dic, oob):
        self.datum_dic = datum_dic
        self.oob = oob
        self.path = []
        self.start_node_key = ''
        self.end_node_key = ''
        self.active_dic = {}
        # node_dic: a dictionary of Nodes indexed on datum.key
        # (only need navigable ones)
        # this is not enough to guarantee a path: there
        # might still be pockets of unreachable passage
        self.node_dic = {}
        # active_dic a dictionary of nodes indexed on datum.key
        # at the beginning it contains only the start node but
        # we add each node as we meet it. When weve dealt with all
        # a nodes neighbours we remove it from active_dic
        for key, datum in list(datum_dic.items()):
            good_datum = False
            for neighbour in datum.neighbours:
                if neighbour not in oob:
                    good_datum = True
                    break
            if good_datum:
                self.node_dic[key] = Node(datum, oob)

    def re_init(self, start, finish):
        for _, datum in list(self.node_dic.items()):
            datum.distance = 999999
            datum.unvisited = True
            datum.predecessor = None

        self.path = []
        self.start_node_key = start
        self.end_node_key = finish
        self.active_dic = {}
        try:
            self.active_dic[
                self.start_node_key] =\
                                       self.node_dic[self.start_node_key]
        except KeyError:
            return
        # check that end node is potentially reachable
        if self.end_node_key in self.node_dic:
            # try to find the path
            self.doit()

    def doit(self):
        success = False
        current_node_key = self.start_node_key
        current_node = self.active_dic[current_node_key]
        current_node.distance = 0
        while True:
            # find active node with shortest distance
            # only active nodes can be part of the path
            current_node = None
            shortest_distance = 999999
            for key, value in list(self.active_dic.items()):
                if value.distance < shortest_distance:
                    shortest_distance = value.distance
                    current_node_key = key
                    current_node = value
            if current_node is not None:
                # update distance for all its unvisited neighbours
                for neighbour_key in current_node.neighbours:
                    try:
                        neighbour_node = self.node_dic[neighbour_key]
                        if neighbour_node.unvisited:
                            # first visit so add it to active
                            self.active_dic[neighbour_node.key] = neighbour_node
                            distance = current_node.distance + 1
                            if distance < neighbour_node.distance:
                                neighbour_node.distance = distance
                                neighbour_node.predecessor = current_node_key
                    except KeyError:
                        pass
                # mark current_node visited and remove it from active_nodes
                current_node.unvisited = False
                del self.active_dic[current_node_key]
                if not self.node_dic[self.end_node_key].unvisited:
                    # we've arrived!
                    success = True
                    break
            else:
                # there is no path!
                break
        if success:
            # find path
            path = []
            current_end = self.end_node_key
            while True:
                path.append(current_end)
                if current_end == self.start_node_key:
                    break
                current_end = self.node_dic[current_end].predecessor
            path.reverse()
            self.path = path


class Datum(object):
    """
    """
    def __init__(self, key, neighbours):
        self.key = key
        self.neighbours = neighbours


class Model(object):
    """
    """
    def __init__(self, model_data, settings):
        self.words = model_data.words
        self.hamming_1_dic = model_data.hamming_1_dic
        self.settings = settings
        self.datum_dic = {}
        self.average_group = None
        self.user_word = ''
        self.start_word = ''
        self.game_path = []
        self.count_hamming_1()

    def count_hamming_1(self):
        total_members = 0
        total_words = len(self.hamming_1_dic)
        for _, wordlist in list(self.hamming_1_dic.items()):
            total_members += len(wordlist)
        self.average_group = float(total_members) / total_words

    def make_datum_dic(self):
        dics = {}
        for key, items in list(self.hamming_1_dic.items()):
            dics[key] = Datum(key, items)
        self.datum_dic = dics

    def find_ladder(self, word_in):
        self.game_path = []
        self.make_datum_dic()
        shuffle(self.words)
        length = self.settings.rungs
        dijkstra = Dijkstra(self.datum_dic, {})
        start_word = word_in
        self.start_word = start_word
        for j in range(len(self.words)):
            end_word = self.words[j]
            dijkstra.re_init(start_word, end_word)
            path = dijkstra.path
            if not path:
                # should not get here!
                pass
            else:
                if len(path) == length:
                    self.game_path = path
                    self.user_word = [path[0]]
                    for _ in range(length - 2):
                        self.user_word.append('')
                    self.user_word.append(path[length - 1])
                    return self.game_path

    def find_specific_ladder(self, end_word):
        self.game_path = []
        self.make_datum_dic()
        shuffle(self.words)
        length = self.settings.rungs
        dijkstra = Dijkstra(self.datum_dic, {})
        start_word = self.start_word
        dijkstra.re_init(start_word, end_word)
        path = dijkstra.path
        if not path:
            # should not get here!
            pass
        else:
            len_path = len(path)
            if len_path <= length:
                self.game_path = path
                self.user_word = [path[0]]
                for _ in range(len_path - 2):
                    self.user_word.append('')
                self.user_word.append(path[len_path - 1])
        return self.game_path

    def find_word(self, word_in):
        found = False
        if word_in in self.words:
            found = True
        return found

    def good_user_word(self, word_in, box_index):
        """
        Must be in words
        Must be hamming_1 from either side
        """

        def is_hamming_1(word_in, test_word):
            count = 0
            for position in range(len(word_in)):
                if word_in[position] != test_word[position]:
                    count += 1
            return count == 1

        good_above = False
        good_below = False
        one = False
        if word_in in self.words:
            good_above = True
            good_below = True
            above = box_index - 1
            if above > -1:
                if self.user_word[above]:
                    good_above = False
                    test_word = self.user_word[above]
                    if is_hamming_1(word_in, test_word):
                        good_above = True
                        one = True

            below = box_index + 1
            if below < self.settings.rungs:
                if self.user_word[below]:
                    good_below = False
                    test_word = self.user_word[below]
                    if is_hamming_1(word_in, test_word):
                        good_below = True
                        one = True

        return good_above and good_below and one

    def set_popup_items(self, rung_index):
        return self.hamming_1_dic[self.game_path[rung_index]]

    def all_words_found(self):
        ret = True
        for word in self.user_word:
            if not word:
                ret = False
        return ret

    def choose_word(self):
        while True:
            word_index = randint(0, len(self.words))
            word = self.words[word_index]
            if len(self.hamming_1_dic[word]) > self.average_group:
                break

        start_time = int(10*time.time())
        maximum_wait = int(self.settings.dic['maximum_wait'])
        while True:
            now = int(10*time.time())
            elapsed_time = now - start_time
            if elapsed_time > maximum_wait * 10:
                self.game_path = []
                return
            if self.find_ladder(word):
                return


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

    def __init__(self, root):
        self.settings = Settings()
        self.master = root
        self.flasher = None
        self.model_data = ModelData()
        word_length = int(self.settings.dic['word_length'])
        self.model_data.make_puzzles(word_length)
        self.model = None
        self.view = View(root, self.settings)
        self.model = Model(self.model_data, self.settings)
        self.view.model = self.model
        self.start_game()

    def start_game(self):
        self.view.clock_init()
        self.view.clock_restart()
        self.view.start_game(True)
        self.add_callbacks()
        self.view.entry_boxes.clear(0)
        self.flasher = Flasher(self.view.clock.box.label,
                               self.master, self.settings)

    def service_toolbar(self, button_index, mouse_index):
        PzlG.logger.logger.info("service_toolbar " +
                                str(button_index) +
                                ' ' +
                                str(mouse_index))

        if mouse_index == 2:
            self.view.do_tooltip(button_index, None)
        elif mouse_index == 3:
            if button_index == 0:
                pass
        elif mouse_index == 1:
            if button_index == 0:
                self.start_game_2()
            elif button_index == 1:
                self.show_solution()

    def service_entry_boxes(self, text, rung_index):
        """
        """
        last_rung = self.settings.rungs - 1
        word = text
        user_word = word.upper()
        if rung_index == 0:
            self.view.config(cursor='watch')
            self.view.update_idletasks()
            if self.model.find_word(user_word):
                self.model.start_word = user_word
                words = self.model.find_ladder(user_word)
                if words:
                    for rung in range(len(words)):
                        self.view.entry_boxes.clear(rung)
                    self.view.entry_boxes.write_box(words[0], 0)
                    self.view.entry_boxes.write_box(words[last_rung],
                                                    last_rung)
                    self.load_popup(0)
                    self.load_popup(last_rung)
                    self.view.start_game(False)
                else:
                    self.view.entry_boxes.clear(rung_index)
            else:
                self.view.entry_boxes.clear(rung_index)
            self.view.config(cursor='arrow')
            self.view.update_idletasks()
        elif rung_index == last_rung:
            self.view.config(cursor='watch')
            self.view.update_idletasks()
            if self.model.find_word(user_word):
                words = self.model.find_specific_ladder(user_word)
                if words:
                    for rung in range(len(words)):
                        self.view.entry_boxes.clear(rung)
                    self.view.entry_boxes.write_box(words[0], 0)
                    last_rung = len(words) - 1
                    self.view.entry_boxes.write_box(words[last_rung],
                                                    last_rung)
                    for rung in range(last_rung + 1, self.settings.rungs):
                        self.view.entry_boxes.clear(rung)
                    self.load_popup(0)
                    self.load_popup(last_rung)
                    self.view.start_game(False)
                else:
                    self.view.entry_boxes.clear(rung_index)
            else:
                self.view.entry_boxes.clear(rung_index)
            self.view.config(cursor='arrow')
            self.view.update_idletasks()
        else:
            self.view.entry_boxes.clear(rung_index)
            if self.model.good_user_word(user_word, rung_index):
                self.model.user_word[rung_index] = user_word
                self.view.entry_boxes.write_box(user_word, rung_index)
                self.load_popup(rung_index)

                self.check_game_over()

    def load_popup(self, rung):
        self.view.board.popups[rung].items = self.model.set_popup_items(rung)

    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 == 'rungs':
            self.set_rungs()
        elif name == 'word_length':
            self.set_word_length()
        elif 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 add_callbacks(self):
        self.view.add_menu_callback(self.service_menu)
        self.view.add_toolbar_callback(self.service_toolbar)
        for _ in range(self.settings.rungs):
            self.view.entry_boxes.add_callback(self.service_entry_boxes)

    def start_game_2(self):
        self.view.config(cursor='watch')
        self.view.update_idletasks()
        self.model.choose_word()
        words = self.model.game_path
        if words:
            self.start_game()
            for rung in range(self.settings.rungs):
                self.view.entry_boxes.clear(rung)
            last_rung = self.settings.rungs - 1
            self.view.entry_boxes.write_box(words[0], 0)
            self.view.entry_boxes.write_box(words[last_rung],
                                            last_rung)
            self.load_popup(0)
            self.load_popup(last_rung)
        self.view.config(cursor='arrow')
        self.view.update_idletasks()

    def set_rungs(self):
        self.settings.rungs = int(self.settings.dic['rungs'])
        self.start_game()

    def set_word_length(self):
        word_length = int(self.settings.dic['word_length'])
        self.model_data.make_puzzles(word_length)
        self.model.words = self.model_data.words
        self.model.hamming_1_dic = self.model_data.hamming_1_dic
        self.start_game()

    def set_font_size(self):
        for rung in range(self.settings.rungs):
            self.view.entry_boxes.clear(rung)
        self.settings.do_scaling()

    def check_game_over(self):
        if self.model.all_words_found():
            self.view.game_over()
            self.view.clock_stop()
            self.view.clock.finished = False
            self.flasher.flash()

    def show_solution(self):
        self.view.game_over()
        self.view.clock_stop()
        self.view.clock.finished = False
        words = self.model.game_path
        for rung in range(len(words)):
            self.view.entry_boxes.clear(rung)
            self.view.entry_boxes.write_box(words[rung], rung)
        self.flasher.flash()


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