#! /usr/bin/env python
# -*- coding: utf-8 -*-
"""
    4digits - A guess-the-number game, aka Bulls and Cows
    Copyright (c) 2004- Yongzhi Pan <http://fourdigits.sourceforge.net>

    4digits is a guess-the-number puzzle game. You are given eight times
    to guess a four-digit number. One digit is marked A if its value and
    position are both correct, and marked B if only its value is correct.
    You win the game when you get 4A0B. Good luck!

    4digits is free software; you can redistribute it and/or
    modify it under the terms of the GNU General Public License as
    published by the Free Software Foundation; either version 2 of
    the License, or (at your option) any later version.

    4digits is distributed in the hope that it will be useful,
    but WITHOUT ANY WARRANTY; without even the implied warranty of
    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
    GNU General Public License for more details.

    You should have received a copy of the GNU General Public License
    along with 4digits; if not, write to the Free Software Foundation,
    Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA
"""

import cPickle
import errno
import os
import random
import sys
import time
import webbrowser
import locale
import gettext
from gettext import gettext as _

import gtk.glade
from glib import GError
import pango

# For distribution packaging
#LOCALE_PATH = '/usr/share/locale/'
LOCALE_PATH = 'locale/'

try:
    import pygtk
    pygtk.require('2.0')
    import gtk
except ImportError:
    print _('python-gtk2 is required to run 4digits.')
    print _('No python-gtk2 was found on your system.')
    sys.exit(1)

gladefiles = [os.path.join(os.path.dirname(__file__), '4digits.glade'),
              "/usr/share/4digits/4digits.glade"]  # For debian packages.
helpfiles = [os.path.join(os.path.dirname(__file__), 'doc', 'index.html'),
            "/usr/share/doc/4digits/index.html"]  # For debian packages.

appdata_dir = os.path.join(os.path.expanduser('~'), '.4digits')
config_path = os.path.join(appdata_dir, 'prefs.pickle')
score_filename = os.path.join(appdata_dir, '4digits.4digits.scores')

prefs = {
        'show toolbar': True,
        'show hint table': False,
        'auto fill hints': False
        }


class MainWindow:
    """The main game window."""
    def __init__(self):
        """GUI initialization."""
        self.widget_tree = gtk.Builder()
        self.widget_tree.set_translation_domain('4digits')
        for gladefile in gladefiles:
            try:
                self.widget_tree.add_from_file(gladefile)
            except GError:
                continue
            else:
                break
        self.toolbar = self.widget_tree.get_object('toolbar')
        self.view_toolbar = self.widget_tree.get_object('view_toolbar')
        self.hint_table = self.widget_tree.get_object('hint_table')
        self.hint_hseparator = self.widget_tree.get_object(
                'hint_hseparator')
        self.view_hint_table = self.widget_tree.get_object(
                'view_hint_table')
        self.auto_fill_hints = self.widget_tree.get_object(
                'view_auto_fill_hints')

        # Input box
        self.entry = self.widget_tree.get_object('entry')
        self.entry.grab_focus()
        fontsize = self.entry.get_pango_context(). \
                get_font_description().get_size() / pango.SCALE
        self.entry.modify_font(pango.FontDescription(str(int(fontsize * 3))))

        self.ok_button = self.widget_tree.get_object('ok_button')
        for widget in ('g0', 'g1', 'g2', 'g3', 'g4', 'g5', 'g6', 'g7',
                'r0', 'r1', 'r2', 'r3', 'r4', 'r5', 'r6', 'r7'):
            setattr(self, widget, self.widget_tree.get_object(widget))
        self.info_label = self.widget_tree.get_object('info_label')
        self.time_label = self.widget_tree.get_object('time_label')
        self.score_view = self.widget_tree.get_object('score_view')

        self.cb_hint = []  # container for check boxes in the hint table
        self.label_hint = []
        self.build_hint_table()

        # about and score dialog
        self.about_dialog = self.widget_tree.get_object('about_dialog')
        self.score_dialog = self.widget_tree.get_object('score_dialog')

        # parse preferences
        self.read_prefs()
        if prefs['show toolbar']:
            self.toolbar.show()
            self.view_toolbar.set_active(True)
        else:
            self.toolbar.hide()
            self.view_toolbar.set_active(False)
        if prefs['show hint table']:
            self.hint_table.show_all()
            self.hint_hseparator.show()
            self.view_hint_table.set_active(True)
        else:
            self.hint_table.hide_all()
            self.hint_hseparator.hide()
            self.view_hint_table.set_active(False)
        if prefs['auto fill hints']:
            self.auto_fill_hints.set_active(True)
        else:
            self.auto_fill_hints.set_active(False)

        # connect signals and callbacks
        dic = {'on_main_window_destroy': self.terminate_program,
                'on_quit_activate': self.terminate_program,
                'on_ok_clicked': self.on_entry_activate,
                'on_new_game_activate': self.on_new_game_activate,
                'on_view_toolbar_toggled': self.on_view_toolbar_toggled,
                'on_view_hint_table_toggled': self.on_view_hint_table_toggled,
                'on_view_auto_fill_hints_toggled':
                    self.on_view_auto_fill_hints_toggled,
                'on_entry_activate': self.on_entry_activate,
                'on_entry_changed': self.on_entry_changed,
                'on_help_activate': self.on_help_activate,
                'on_about_activate': self.on_about_activate,
                'on_score_activate': self.on_score_activate}
        self.widget_tree.connect_signals(dic)
        # new game initialization
        self.game = NewRound()

    def read_prefs(self):
        """Read preferences data from disk.
        Copied from Comix.
        """
        # TODO: use with context manager
        if os.path.isfile(config_path):
            try:
                config = open(config_path)
                old_prefs = cPickle.load(config)
                config.close()
            except Exception:  # FIXME: Need specific exceptions
                print _('Corrupted preferences file "%s", deleting...') \
                        % config_path
                os.remove(config_path)
            else:
                # TODO: use dict comprehension
                for key in old_prefs:
                    if key in prefs:
                        prefs[key] = old_prefs[key]

    def build_hint_table(self):
        """Create the controls for the hint table."""
        hint_table = self.widget_tree.get_object('hint_table')
        for name in (0, 40):
            table = gtk.Table(rows=11, columns=6)
            hint_table.pack_start(table)

            # Create row labels
            for row in range(10):
                label = gtk.CheckButton(str(row))
                table.attach(label, 0, 1, row + 1, row + 2)
                label.connect('toggled', self.change_row, row + name)
                self.label_hint.append(label)

            for col in range(1, 5):
                # Create column labels
                #label = gtk.Label(str(col))
                #table.attach(label, col, col+1, 0, 1)
                # Create Checkboxes
                for row in range(10):
                    checkbutton = gtk.CheckButton()
                    table.attach(checkbutton, col, col + 1, row + 1, row + 2)
                    self.cb_hint.append(checkbutton)

            if name == 0:  # First table
                hint_table.pack_start(gtk.VSeparator())
        self.init_hint_table()

    def change_row(self, widget, row):
        """Toggle a rows state."""
        enable = widget.get_active()
        for col in range(4):
            self.cb_hint[10 * col + row].set_sensitive(enable)

    def init_hint_table(self):
        """Reset all controls in the hinttable to their default state."""
        for i in range(40):
            self.cb_hint[i].set_active(True)
            self.cb_hint[i + 40].set_active(False)
            self.cb_hint[i + 40].set_sensitive(False)
        self.cb_hint[0].set_active(False)
        for row in range(10):
            self.label_hint[row].set_active(True)
            self.label_hint[row + 10].set_active(False)

    #         1    2    3    4                   1    2    3    4
    #  0/0    00   10   20   30          0/10    40   50   60   70
    #  1/1    01   11   21   31          1/11    41   51   61   71
    #  2/2    02   12   22   32          2/12    42   52   62   72
    # ...

    def get_checkbox(self, row, col, tablenr=0):
        """Get the checkbox at the specified position."""
        return self.cb_hint[tablenr * 40 + col * 10 + row]

    def get_label(self, row, tablenr=0):
        """Get the label at the specified position."""
        return self.label_hint[tablenr * 10 + row]

    def on_entry_activate(self, widget):
        """when input is accepted."""
        bulls, cows = 0, 0
        number = ''
        # check input
        if self.game.guess < 8:
            number = self.entry.get_text()
            if number == '':
                self.process_error(_('Must input something.'))
                return False
            elif number[0] == '0':
                self.process_error(_('First digit cannot be zero.'))
                return False
            try:
                number = repr(int(number))
            except ValueError:
                self.process_error(_('Must input a number.'))
                return False
            if len(number) < 4:
                self.process_error(_('Must input four digits.'))
                return False
            elif len(set(number)) < 4:
                self.process_error(_('Four digits must be unique.'))
                return False
            elif number in self.game.guesses:
                self.process_error(_("You've already guessed it."))
                return False
            self.game.guesses.append(number)
            # process input
            for i in range(4):
                for j in range(4):
                    if self.game.answer[i] == int(number[j]):
                        if i == j:
                            bulls += 1
                        else:
                            cows += 1
            guess_label = getattr(self, 'g' + repr(self.game.guess))
            result_label = getattr(self, 'r' + repr(self.game.guess))
            guess_label.set_text(number)
            result_label.set_text('%dA%dB' % (bulls, cows))

            if self.auto_fill_hints.get_active():
                self.fill_hints(number, bulls, cows)

            # win
            if bulls == 4:
                self.info_label.set_text(_('You win! :)'))
                self.get_time_taken_till_now()
                self.time_label.set_text(_('Used %.1f s.') %
                    self.game.time_taken)
                self.ok_button.set_sensitive(False)
                self.entry.set_sensitive(False)
                if self.is_high_score(self.game.time_taken):
                    new_score_rank = self.save_score(self.game.time_taken)
                    self.show_score(new_score_rank)
            # lose
            elif self.game.guess == 7:
                answer = ''
                for i in range(4):
                    answer += repr(self.game.answer[i])
                self.info_label.set_text(_('Haha, you lose. It is %s.') %
                    answer)
                self.get_time_taken_till_now()
                self.time_label.set_text(_('Wasted %.1f s.') %
                    self.game.time_taken)
                self.ok_button.set_sensitive(False)
                self.entry.set_sensitive(False)
        self.game.guess += 1
        self.entry.grab_focus()

    def clear_row(self, row):
        """Clear a complete row."""
        self.get_label(row, 0).set_active(False)
        for col in range(4):
            self.get_checkbox(row, col, 0).set_active(False)
            self.get_checkbox(row, col, 1).set_active(False)

    def fill_hints(self, number, bulls, cows):
        """Auto filling some obvious cases in the hint table."""
        number = [int(x) for x in number]
        if bulls == 0 and cows == 0:
            for digit in number:
                self.clear_row(digit)
            return  # TODO: what does this do?

        if bulls + cows == 4:
            for digit in range(10):
                if digit in number:
                    self.get_label(digit, 0).set_active(True)
                    self.get_label(digit, 1).set_active(True)
                else:
                    self.clear_row(digit)

        if bulls == 0:  # Only cows
            for digit_pos in range(4):  # Adjust line breaking
                self.get_checkbox(number[digit_pos], digit_pos,
                    0).set_active(False)
                self.get_checkbox(number[digit_pos], digit_pos,
                    1).set_active(False)

        if cows == 0:  # Only bulls
        # Only digits which are either in the right place or completely wrong
            for digit_pos in range(4):
                for pos2 in range(4):
                    if pos2 == digit_pos:
                        continue
                    # uncheck impossible boxes in first column
                    self.get_checkbox(number[digit_pos], pos2,
                        0).set_active(False)

    def on_entry_changed(self, widget):
        """Start timer as soon as the user enters the first digit."""
        self.info_label.set_text('')
        if self.game.on_entry_cb_first_called == True:
            self.time_label.set_text(_('Timer started...'))
            self.game.time_start = time.time()
            self.game.on_entry_cb_first_called = False

    def on_view_toolbar_toggled(self, widget):
        """Toggle toolbar visibility."""
        if self.toolbar.get_property('visible'):
            self.toolbar.hide()
            prefs['show toolbar'] = False
        else:
            self.toolbar.show()
            prefs['show toolbar'] = True

    def on_view_hint_table_toggled(self, widget):
        """Toggle hint table visibility."""
        if self.hint_table.get_property('visible'):
            self.hint_table.hide_all()
            self.hint_hseparator.hide()
            prefs['show hint table'] = False
        else:
            self.hint_table.show_all()
            self.hint_hseparator.show()
            prefs['show hint table'] = True

    def on_view_auto_fill_hints_toggled(self, widget):
        """Toggle auto filling of hint table."""
        if self.auto_fill_hints.get_active():
            prefs['auto fill hints'] = True
        else:
            prefs['auto fill hints'] = False

    @staticmethod
    def on_help_activate(widget):
        """Show help."""
        for helpfile in helpfiles:
            try:
                # webbrowser.open does not raise a catchable exception.
                open(helpfile)
            except IOError:
                continue
            else:
                break
        webbrowser.open(helpfile)

    def on_about_activate(self, widget):
        """Show about dialog."""
        self.about_dialog.run()
        self.about_dialog.hide()

    def on_score_activate(self, new_score_rank):
        """Show high scores."""
        sv_selection = self.score_view.get_selection()
        sv_selection.set_mode(gtk.SELECTION_NONE)

        # Since we hide but don't destory the score dialog, we have to remove
        # all columns before appending, otherwise we would have more and more
        # columns.
        for col in self.score_view.get_columns():
            self.score_view.remove_column(col)
        # FIXME: column header not translated under windows
        columns = [_('Name'), _('Score'), _('Date')]
        for p in enumerate(columns):
            column = gtk.TreeViewColumn(p[1], gtk.CellRendererText(),
                    text=p[0])
            self.score_view.append_column(column)

        scoreList = gtk.ListStore(str, str, str)
        self.score_view.set_model(scoreList)

        try:
            scores = [line.split(' ', 6)
                    for line in file(score_filename, 'r')]
        except IOError:
            scores = []

        for line in scores:
            score_tup = line[0], line[1], ' '.join(line[2:]).rstrip('\n')
            scoreList.append(score_tup)
        # high light the current high score entry
        try:
            sv_selection.set_mode(gtk.SELECTION_SINGLE)
            sv_selection.select_path(new_score_rank)
        except TypeError:
            sv_selection.set_mode(gtk.SELECTION_NONE)

        self.score_dialog.run()
        self.score_dialog.hide()

    def on_new_game_activate(self, widget):
        """New game initialization."""
        self.game = NewRound()
        self.ok_button.set_sensitive(True)
        self.entry.set_sensitive(True)
        self.entry.grab_focus()
        # won't start the timer when you just start a new game
        self.game.on_entry_cb_first_called = False
        self.entry.set_text('')
        self.game.on_entry_cb_first_called = True
        self.info_label.set_text(_('Ready'))
        self.time_label.set_text('')

        for i in range(8):
            getattr(self, 'g' + repr(i)).set_text('')
            getattr(self, 'r' + repr(i)).set_text('')

        self.init_hint_table()

    def process_error(self, msg):
        """Show error message in statusbar."""
        self.info_label.set_text(msg)
        self.entry.grab_focus()

    def get_time_taken_till_now(self):
        """Get time since start of the game."""
        self.game.time_end = time.time()
        self.game.time_taken = self.game.time_end - self.game.time_start
        self.game.time_taken = round(self.game.time_taken, 1)

    @staticmethod
    def is_high_score(time_taken):
        """Is this time a highscore."""
        try:
            scores = [line.split(' ', 6)
                    for line in file(score_filename, 'r')]
        except IOError:
            return True  # List does not exist yet
        if len(scores) < 10:
            return True
        scores = sorted(scores, key=lambda x: float(x[1][:-1]))
        if time_taken < float(scores[-1][1][:-1]):
            return True
        else:
            return False

    def save_prefs(self):
        """Save preference data to disk.
        Copied from Comix.
        """
        # FIXME: use with context manager
        try:
            config = open(config_path, 'w')
        except IOError as (errnum, strerror):
            if errnum == errno.ENOENT:
                try:
                    os.mkdir(appdata_dir)
                except IOError:
                    print _("Cannot open score file.\n");
                    sys.exit(1)
                config = open(config_path, 'w')
        except:
            print _("Cannot open score file.\n");
            sys.exit(1)

       # Here `else' cannot be used.
        cPickle.dump(prefs, config, cPickle.HIGHEST_PROTOCOL)
        config.close()

    @staticmethod
    def save_score(time_taken):
        """Save highscore file."""
        date = time.strftime("%a %b %d %H:%M:%S %Y")
        name = 'USERNAME' if sys.platform == 'win32' else 'USER'
        new_score = "%s %ss %s\n" % (os.getenv(name), time_taken, date)
        # FIXME: use with context manager
        try:
            saved_scores = open(score_filename, 'r').readlines()
        except IOError:
            saved_scores = []
        saved_scores.append(new_score)
        scores = [line.split(' ', 6) for line in saved_scores]
        scores = sorted(scores, key=lambda x: float(x[1][:-1]))
        scores = scores[:10]

        # find the index of the new score
        new_score = new_score.split(' ', 6)
        new_score_rank = scores.index(new_score)

        # FIXME: use with context manager
        try:
            scorefile = open(score_filename, 'w')
        except IOError as (errnum, strerror):
            if errnum == errno.ENOENT:
                try:
                    os.mkdir(appdata_dir)
                except IOError:
                    print _("Cannot open score file.\n");
                    sys.exit(1)
                scorefile = open(score_filename, 'w')
        except:
            print _("Cannot open score file.\n");
            sys.exit(1)

        # XXX: what?
        # Here `else' cannot be used.
        for score in scores:
            scorefile.write(' '.join(score))
        scorefile.close()
        return new_score_rank

# XXX: Are these really needed?
#        if not os.path.exists(appdata_dir):
#            os.mkdir(appdata_dir)
#        elif not os.path.isdir(appdata_dir):
#            os.rename(appdata_dir, appdata_dir+'.orig')
#            os.mkdir(appdata_dir)
#        scorefile = open(score_filename, 'w')

    def show_score(self, new_score_rank):
        """Show highscore dialog."""
        self.on_score_activate(new_score_rank)

    def terminate_program(self, widget):
        """Run clean-up tasks and exit.
        Copied from comix.
        """
        self.save_prefs()
        gtk.main_quit()


class NewRound:
    """Contains data in one round of the game."""
    def __init__(self):
        while True:
            self.answer = random.sample(range(10), 4)
            if self.answer[0] != 0:  # first digit cannot be zero
                break
        #print self.answer
        if self.answer == [4, 6, 1, 9]:
            gtk.MessageDialog(message_format=_(
                   '4619: You are the luckiest guy on the planet!')).show()
        self.guess = 0
        self.guesses = []
        self.time_start = 0
        self.time_end = 0
        self.time_taken = 0
        self.on_entry_cb_first_called = True

if __name__ == "__main__":
    #prepare i18n
    APP = "4digits"
    locale.setlocale(locale.LC_ALL, '')

    # Fix for Windows
    # https://bugzilla.gnome.org/show_bug.cgi?id=574520#c14
    if sys.platform == 'win32':
        from ctypes import cdll
        libintl = cdll.intl
        libintl.bindtextdomain(APP, LOCALE_PATH)
    else:
        locale.bindtextdomain(APP, LOCALE_PATH)
    gettext.bindtextdomain(APP, LOCALE_PATH)
    gettext.textdomain(APP)
    #gtk.glade.bindtextdomain(APP, LOCALE_PATH)
    #gtk.glade.textdomain(APP)

    MainWindow()
    gtk.main()
