#!/usr/bin/env python3
# -*- coding: utf-8 -*- Time-stamp: <2024-01-01 19:48:03 sander>

# pymerid: Meridians game viewer
PYMERID_AUTHOR  = 'Rolf Sander'
PYMERID_EMAIL   = 'mail@rolf-sander.net'
PYMERID_VERSION = '1.0.0'
PYMERID_YEAR    = '2024'
PYMERID_WEBPAGE = 'https://gitlab.com/RolfSander/pymerid'

##############################################################################

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

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

# You should have received a copy of the GNU General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.

##############################################################################

# Create an executable with pyinstaller:
# pip install pyinstaller
# pyinstaller --onefile --noconsole --hidden-import='PIL._tkinter_finder' pymerid.py

##############################################################################

from tkinter import Tk, Canvas, END, Menu, Text, Toplevel, Button, Label, filedialog
from datetime import datetime
import sys
import numpy as np
import re
import os
from PIL import Image, ImageTk

# Define some constants:
DEBUG = False
WHITE = 1
BLACK = 2
COLORCHAR = ['',      'W',     'B']     # color character
COLORNAME = ['Empty', 'White', 'Black'] # color name
DIRECTIONS = [(-1,-1), (0,-1), (1,0), (1,1), (0,1), (-1,0)] # N, NW, SW, S, SE, NE
# For Tkinter:
WINDOW_WIDTH  = 1000
WINDOW_HEIGHT =  900
N15 = ('TkDefaultFont', '15') # normal
B15 = ('TkDefaultFont', '15', 'bold')
B20 = ('TkDefaultFont', '20', 'bold')

##############################################################################

class Game():
    """Methods and attributes for a Meridians game
    """
    def __init__(self):
        pass

    def read_sgf_file(self, filename):
        self.PW = COLORNAME[WHITE] # name of white player
        self.PB = COLORNAME[BLACK] # name of black player
        self.DT = '????-??-??'     # date
        self.RU = ''               # Pie rule?
        self.SZ = 7                # default board size
        self.movenumber = 0        # move 0 = empty board
        self.variation  = 0        # if > 0, movenumber where variation starts
        self.removed    = []       # list of removed stones at each move
        self.lbl2idx    = {}       # directory that returns index of a point
        self.neighbors  = {}       # directory that returns all neighbors of a point
        self.edges      = {}       # directory that returns edges to connected stones
        self.move       = ['']     # list of moves
        self.endofgame  = False    # reached the end of the game?
        with open(filename, 'r') as f:
            filecontents = f.read().replace('\n', '').split(';')
        for move in filecontents:
            result = re.search('[WB]\[[A-z][0-9]+\]', move)
            if (result):
                self.move.append(result.group(0))
            else:
                # Read header data:
                DT = re.findall(r'\bDT\[([^]]*)\]', move)
                GM = re.findall(r'\bGM\[([^]]*)\]', move)
                PB = re.findall(r'\bPB\[([^]]*)\]', move)
                PW = re.findall(r'\bPW\[([^]]*)\]', move)
                RU = re.findall(r'\bRU\[([^]]*)\]', move)
                SZ = re.findall(r'\bSZ\[([^]]*)\]', move)
                if (DT): self.DT = DT[0]
                if (PB): self.PB = PB[0]
                if (PW): self.PW = PW[0]
                if (RU): self.RU = RU[0]
                if (SZ): self.SZ = int(SZ[0])
                if (GM):
                    self.GM = int(GM[0])
                    if (not self.GM==776):
                        popup_window('ERROR',
                                     'The game number in the sgf file should be GM[776].')
        self.Npoints    = 3*self.SZ**2-5*self.SZ+2 # number of points on the board
        self.Ncoord     = 2 * gm.SZ - 2 # number of coordinates, A-L = 12 for normal size
        self.stone      = np.full(self.Npoints, 0, dtype=int) # empty=0, W=1, B=2
        self.halo       = np.empty(self.Npoints, dtype=bool)  # endangered stones
        self.poss       = np.empty(self.Npoints, dtype=bool)  # possible moves
        self.idx2letnum = np.empty((self.Npoints,2), dtype=int)
        self.idx2lbl    = list(range(self.Npoints)) # string representation of a point
        self.Nmoves     = len(self.move) # number of moves in this game

##############################################################################

def toggle_stone_labels():
    cnv.show_labels = not cnv.show_labels
    if (cnv.show_labels):
        cnv.show_numbers = False
        cnv.itemconfig('movenum', state='hidden')
        cnv.itemconfig('label',   state='normal')
        cnv.tag_raise('label')
    else:
        cnv.itemconfig('label',   state='hidden')

def toggle_move_numbers():
    cnv.show_numbers = not cnv.show_numbers
    if (cnv.show_numbers):
        cnv.show_labels = False
        cnv.itemconfig('label',   state='hidden')
        show_move_numbers()
    else:
        cnv.itemconfig('movenum', state='hidden')

def toggle_groups():
    cnv.show_groups = not cnv.show_groups
    if (cnv.show_groups): cnv.itemconfig('groupedges', state='normal')
    else:                 cnv.itemconfig('groupedges', state='hidden')

def toggle_coordinates():
    cnv.show_coord = not cnv.show_coord
    if (cnv.show_coord): cnv.itemconfig('coord', state='normal')
    else:                cnv.itemconfig('coord', state='hidden')

def toggle_halo(newval=None):
    if (newval):
        cnv.show_halo = newval
    else:
        cnv.show_halo = not cnv.show_halo
    if (cnv.show_halo):
        nextcolor = WHITE + BLACK - get_color(gm.movenumber)
        show_halo(nextcolor)
    else:
        cnv.itemconfig('halo', state='hidden')

def toggle_possible_moves(newval=None):
    if (newval):
        cnv.show_poss = newval
    else:
        cnv.show_poss = not cnv.show_poss
    if (cnv.show_poss):
        nextcolor = WHITE + BLACK - get_color(gm.movenumber)
        show_possible_moves(nextcolor)
    else:
        cnv.itemconfig('poss', state='hidden')

def key_pressed(event=None):
    key = event.char
    if (key=='c'): toggle_coordinates()    # c = toggle coordinates
    if (key=='g'): toggle_groups()         # g = toggle groups
    if (key=='h'): toggle_halo()           # h = toggle halo
    if (key=='l'): toggle_stone_labels()   # l = toggle labels
    if (key=='n'): toggle_move_numbers()   # n = toggle move numbers
    if (key=='p'): toggle_possible_moves() # p = toggle possible moves
    if (key=='q'): gui.destroy()           # q = quit
    if (key=='r'): show_rules()            # r = show rules

def show_about():
    copyright = chr(169)
    popup_window('About Pymerid',
                 'Game Viewer for Meridians\n' +
                 f'Version {PYMERID_VERSION}\n' +
                 f'{copyright} {PYMERID_AUTHOR}, {PYMERID_YEAR}\n' +
                 f'{PYMERID_EMAIL}\n' +
                 f'{PYMERID_WEBPAGE}\n' +
                 'License: GPL')

def show_rules():
    rulewindow = Toplevel()
    rulewindow.title('Rules')
    ruletext = Text(rulewindow, borderwidth=0, height = 50, width = 80)
    with open('rules.txt', 'r') as f:
        ruletext.insert(END, f.read())
    ruletext.pack(padx=20, pady=20)
    Button(rulewindow, text='OK', font='-weight bold',
           command=rulewindow.destroy).pack()

# https://stackoverflow.com/questions/10301000
def get_all_connected_groups(graph):
    def get_connected_group(node, already_seen):
        result = []
        nodes = set([node])
        while nodes:
            node = nodes.pop()
            already_seen.add(node)
            nodes.update(n for n in graph[node] if n not in already_seen)
            result.append(node)
        return result, already_seen
    already_seen = set()
    result = []
    for node in graph:
        if node not in already_seen:
            connected_group, already_seen = get_connected_group(node, already_seen)
            result.append(connected_group)
    return result

def list_groups(grouplist, color):
    print(f'\n{COLORNAME[color]} groups at move: {gm.movenumber}')
    for i, group in enumerate(grouplist):
        print(i, end=': ')
        for idx in group:
            print(gm.idx2lbl[idx], end=' ')
        print()

def get_groups(color):
    cnv.delete('groupedges')
    statelabel = 'normal' if cnv.show_groups else 'hidden'
    fillcolor = ['green', 'blue']
    # Create a graph that contains edges between all points that are
    # neighbors and occupied with stones of the same color:
    graph = {}
    for idx in range(gm.Npoints):
        if (gm.stone[idx]==color):
            set_of_neighbors = set()
            for neighbor in gm.neighbors[idx]:
                # Add edge:
                idx_neighbor = gm.lbl2idx[neighbor]
                # Is a stone of the same color on neighbor point?
                if (gm.stone[idx_neighbor]==color):
                    set_of_neighbors.add(idx_neighbor)
                    # Draw a line between neighbors:
                    xy1 = calc_xy(*gm.idx2letnum[idx])
                    xy2 = calc_xy(*gm.idx2letnum[idx_neighbor])
                    cnv.create_line((xy1,xy2), width=3, tags='groupedges',
                                    state=statelabel, fill=fillcolor[color-1])
            graph[idx] = set_of_neighbors
    # Create a list of all connected groups of the graph:
    grouplist = get_all_connected_groups(graph)
    cnv.itemconfig('groupnumber', state='hidden')
    cnv.tag_raise('groupnumber')
    if (DEBUG): list_groups(grouplist, color)
    return grouplist

def show_move_numbers():
    cnv.itemconfig('movenum', state='hidden')
    if (cnv.show_numbers):
        for i in range1(gm.movenumber-1):
            idx = get_idx(i)
            label = gm.idx2lbl[idx]
            cnv.itemconfig(f'movenum&&{label}', state='normal', text=i)
        cnv.tag_raise('movenum')

def show_possible_moves(color):
    if (cnv.show_poss):
        if (color==WHITE): phimg = cnv.pwimg
        if (color==BLACK): phimg = cnv.pbimg
        if (gm.movenumber<2):
            gm.poss[:] = True
        else:
            gm.poss[:] = False
            for idx in range(gm.Npoints):
                if (gm.stone[idx]==color):
                    let, num = gm.idx2letnum[idx]
                    for direction in DIRECTIONS:
                        let2 = let
                        num2 = num
                        cont = True
                        direct_neighbor = True
                        while cont:
                            cont = False
                            # Go to next point in this direction:
                            let2 += direction[0]
                            num2 += direction[1]
                            if (is_valid(let2, num2)): # is it valid, not off the board?
                                lbl2 = letnum2lbl(let2,num2)
                                idx2 = gm.lbl2idx[lbl2]
                                if (gm.stone[idx2]==0): # point is empty
                                    cont = True # continue while loop to check next point
                                    # Suicide not allowed at 2nd move of each player:
                                    if ((gm.movenumber>3) or (not direct_neighbor)):
                                        gm.poss[idx2] = True
                                    direct_neighbor = False
        for idx in range(gm.Npoints):
            if (gm.poss[idx]):
                cnv.itemconfig(f'poss&&{gm.idx2lbl[idx]}',
                               activeimage=phimg, state='normal')

def is_group_safe(grouplist):
    group_safe = [False for i in grouplist] # set all group_safe = False
    groupnum = np.full(gm.Npoints, 0, dtype=int) #  group number
    for i, group in enumerate(grouplist):
        for idx in group:
            groupnum[idx] = i
    for group in grouplist:
        for idx in group:
            let, num = gm.idx2letnum[idx]
            for direction in DIRECTIONS:
                let2 = let
                num2 = num
                cont = True
                while cont:
                    cont = False
                    # Go to next point in this direction:
                    let2 = let2 + direction[0]
                    num2 = num2 + direction[1]
                    if (is_valid(let2, num2)): # is nextpoint valid, not off the board?
                        idx2 = gm.lbl2idx[letnum2lbl(let2,num2)]
                        if (gm.stone[idx2]==0): # point is empty
                            cont = True # continue while loop to check next point
                        elif (gm.stone[idx]==gm.stone[idx2]): # stone of same color
                            # Check if different group number:
                            if (not groupnum[idx]==groupnum[idx2]):
                                group_safe[groupnum[idx]] = True
    return group_safe

def kill_dead_stones(grouplist, color):
    if (gm.movenumber>3): # don't check group status at beginning
        group_safe = is_group_safe(grouplist)
        removed_stones = []
        for i, group in enumerate(grouplist):
            if (DEBUG): print(color,i, group_safe[i], group, [gm.idx2lbl[j] for j in group])
            if (not group_safe[i]):
                for idx in group:
                    remove_stone(idx) # remove stone
                    removed_stones.append(idx)
        gm.removed.append(removed_stones)

def show_halo(color):
    if (cnv.show_halo):
        grouplist = get_groups(color)
        if (gm.movenumber>3): # don't check group status at beginning
            gm.halo[:] = False
            group_safe = is_group_safe(grouplist)
            for i, group in enumerate(grouplist):
                if (DEBUG): print(i, group_safe[i], group, [gm.idx2lbl[j] for j in group])
                if (not group_safe[i]):
                    for idx in group:
                        gm.halo[idx] = True
                        cnv.itemconfig(f'halo&&{gm.idx2lbl[idx]}', state='normal')

def update_info(color, idx):
    cnv.itemconfig('mvnum', text=f'Move: {gm.movenumber}')
    cnv.itemconfig('thismv',   text=f'{COLORCHAR[color]} at {gm.idx2lbl[idx]}')
    if ((gm.movenumber>=gm.Nmoves-1) and (gm.variation==0)):
        cnv.itemconfig('nxtcol', text='THE END')
    else:
        cnv.itemconfig('nxtcol', text=f'Next: {COLORNAME[3-color]}')

def print_game_status(func): # only for debugging
    print(f'Game status at move {gm.movenumber} after {func} ', end ='')
    if (gm.variation>0):
        print(f'{gm.vari[gm.movenumber]} (variation = {gm.variation})')
        [print(f'{i} ', end='') for i in gm.vari[:gm.movenumber+1]] ; print('VARI')
    else:
        print(f'{gm.move[gm.movenumber]}')
    [print(f'{i} ', end='') for i in gm.move[:gm.movenumber+1]] ; print('GAME')
    for color in (WHITE, BLACK):
        print(f'{COLORNAME[color]} stones at: ', end='')
        for idx in range(gm.Npoints):
            if (gm.stone[idx]==color):
                print(gm.idx2lbl[idx], end=' ')
        print()
    print(f'Removed stones at: ', end='')
    for move in gm.removed:
        print('[', end='')
        for idx in move:
            print(gm.idx2lbl[idx], end=' ')
        print(']', end=' ')
    print()
    print()

def get_idx(movenum):
    # read the move from gm.move (or, if inside a variation, from gm.vari):
    if (gm.variation==0):
        move = gm.move[movenum] # e.g.: 'W[F12]' or 'B[G13]'
    else:
        move = gm.vari[movenum]
    label = move[2:-1]          # ->      'F12'  or   'G13'
    # check that the color is correct:
    if (not COLORCHAR[get_color(movenum)]==move[0].upper()):
        print(f'ERROR at move {movenum}: {COLORCHAR[get_color(movenum)]} {move[0].upper()}')
    idx = gm.lbl2idx[label]
    return idx

def get_color(movenum):
    # https://stackoverflow.com/questions/21837208
    if (movenum & 1): # bitwise AND operator
        return WHITE  # move number is odd
    else:
        return BLACK  # move number is even

def prev_move(event=None):
    if (gm.movenumber==0): return
    gm.endofgame = False
    thiscolor = get_color(gm.movenumber)

    # Remove all halos, possible moves and red dots:
    cnv.itemconfig('halo',   state='hidden')
    cnv.itemconfig('poss',   state='hidden')
    cnv.itemconfig('reddot', state='hidden')

    # Re-insert killed stones:
    if (gm.movenumber>3): # don't check group status at beginning
        for idx in gm.removed.pop():
            put_stone(idx, thiscolor)

    # Remove stone:
    idx = get_idx(gm.movenumber)
    remove_stone(idx)

    # Decrease move number:
    gm.movenumber -= 1
    if (gm.variation>0):
        gm.variation -= 1
        gm.vari.pop() # remove last move from list

    # Show halo and possible moves for next player:
    show_halo(thiscolor)
    show_possible_moves(thiscolor)

    if (gm.movenumber==0):
        cnv.itemconfig('mvnum',  text='')
        cnv.itemconfig('thismv', text='')
        cnv.itemconfig('nxtcol', text='THE START')
        gm.variation = 0
    else:
        thismove  = get_idx(gm.movenumber)
        thiscolor = get_color(gm.movenumber)
        # Show red dot on the last stone:
        thislabel = gm.idx2lbl[thismove]
        cnv.itemconfig(f'reddot&&{thislabel}', state='normal')
        show_move_numbers()
        update_info(thiscolor, thismove)
    cnv_configure_buttons()
    if (DEBUG): print_game_status('prev_move')

def first_move(event=None):
    cnv.itemconfig('wiptxt', text='going to first move...')
    cnv.update() # show above text immediately
    while gm.movenumber>0:
        prev_move()
    cnv.itemconfig('wiptxt', text='')

def backtogame(event=None):
    while gm.variation>0:
        prev_move()

def last_move(event=None):
    if (gm.variation>0): return # deactivated during variation
    cnv.itemconfig('wiptxt', text='going to last move...')
    cnv.update() # show above text immediately
    while gm.movenumber<gm.Nmoves-1:
        next_move()
    cnv.itemconfig('wiptxt', text='')

def next_move(event=None, thiscolor=None, thismove=None):
    if (gm.endofgame): return
    if (gm.variation==0):
        if (gm.movenumber>=gm.Nmoves-1): return
        thismove  = get_idx(gm.movenumber+1)
        thiscolor = get_color(gm.movenumber+1)
    else:
        if (not thiscolor): return
    gm.movenumber += 1

    # Remove all previous halos, possible moves and red dots:
    cnv.itemconfig('halo',   state='hidden')
    cnv.itemconfig('poss',   state='hidden')
    cnv.itemconfig('reddot', state='hidden')

    # Put new stone on board:
    othercolor = WHITE + BLACK - thiscolor
    thislabel = gm.idx2lbl[thismove]
    cnv.itemconfig(f'reddot&&{thislabel}', state='normal') # red dot on current stone
    put_stone(thismove, thiscolor) # show current stone

    # Kill own dead stones:
    kill_dead_stones(get_groups(thiscolor), thiscolor)

    # Show halo and possible moves for next player:
    show_halo(othercolor)
    show_possible_moves(othercolor)
    show_move_numbers()

    update_info(thiscolor, thismove)
    cnv_configure_buttons()
    end_of_game_check()
    if (DEBUG): print_game_status('next_move')

def click_move(label):
    if (gm.variation==0):
        # The following if works because of short-circuit evaluation:
        if ((gm.movenumber<gm.Nmoves-1) and
            (label==gm.idx2lbl[get_idx(gm.movenumber+1)])):
            # The click was on the same point as in the game:
            next_move()
            return
        else:
            # Starting a variation:
            gm.vari = gm.move[:gm.movenumber+1]
    gm.variation += 1
    nextcolor = WHITE + BLACK - get_color(gm.movenumber)
    nextmove = gm.lbl2idx[label]
    move = f'{COLORCHAR[nextcolor]}[{label}]'
    gm.vari.append(move)
    next_move(None, nextcolor, nextmove)

def range1(n):
    # range1 goes from 1 to n (unlike range which goes from 0 to n-1)
    return [i+1 for i in range(n)]

def calc_xy(let, num):
    # Calculate x and y position on canvas:
    col = num - let
    row = num + let
    # Diameter of stone image = 52 = sqrt(60^2 - 30^2)
    x_canvas = col * 52 + WINDOW_WIDTH/2 # col * x_step + x_offset
    y_canvas = row * 30                  # row * y_step
    return (x_canvas, y_canvas)

def letnum2lbl(let, num): # return position label, e.g.: "A1" or "F3"
    return f'{chr(let+64)}{num}'

def remove_stone(idx):
    thislabel = gm.idx2lbl[idx]
    # Make stone invisible with state='hidden':
    cnv.itemconfig(f'{COLORNAME[gm.stone[idx]]}&&{thislabel}', state='hidden')
    if (DEBUG): print(f'removing {thislabel}')
    gm.stone[idx] = 0

def put_stone(idx, color):
    gm.stone[idx] = color
    thislabel = gm.idx2lbl[idx]
    # Make stone visible with state='normal':
    cnv.itemconfig(f'{COLORNAME[color]}&&{thislabel}', state='normal')
    if (DEBUG): print(thislabel, color)

def cnv_draw_board():
    # Draw vertical lines:
    for col in range(1-gm.SZ, gm.SZ): # from -6 to 6 for normal size
        linelen  = gm.Ncoord - abs(col)
        firstcol = abs(col)
        lettop = 1 + (firstcol-col)//2 # integer division (more precisely: floor division)
        numtop = 1 + (firstcol+col)//2 # integer division (more precisely: floor division)
        letbtm = lettop + linelen - 1
        numbtm = numtop + linelen - 1
        if (DEBUG): print(f'{letnum2lbl(lettop,numtop)}-{letnum2lbl(letbtm,numbtm)}')
        xy1 = calc_xy(lettop, numtop)
        xy2 = calc_xy(letbtm, numbtm)
        cnv.create_line((xy1,xy2), tags='board', width=3)
    # Draw slanted lines for each letter:
    for let in range1(gm.Ncoord):
        linelen  = int(2*gm.SZ-1.5-abs(let-gm.SZ+0.5))
        firstnum = max(1, let-gm.SZ+1)
        if (DEBUG): print(f'{letnum2lbl(let,firstnum)}-{letnum2lbl(let,firstnum+linelen-1)}')
        xy1 = calc_xy(let, firstnum)
        xy2 = calc_xy(let, firstnum+linelen-1)
        cnv.create_line((xy1,xy2), tags='board', width=4, fill='#AAAAAA') # antialiasing
        cnv.create_line((xy1,xy2), tags='board', width=3)
    # Draw slanted lines for each number:
    for num in range1(gm.Ncoord):
        linelen  = int(2*gm.SZ-1.5-abs(num-gm.SZ+0.5))
        firstlet = max(1, num-gm.SZ+1)
        if (DEBUG): print(f'{letnum2lbl(firstlet,num)}-{letnum2lbl(firstlet+linelen-1,num)}')
        xy1 = calc_xy(firstlet, num)
        xy2 = calc_xy(firstlet+linelen-1, num)
        cnv.create_line((xy1,xy2), tags='board', width=4, fill='#AAAAAA') # antialiasing
        cnv.create_line((xy1,xy2), tags='board', width=3)
    # Draw points:
    idx = 0
    for let in range1(gm.Ncoord):
        linelen  = int(2*gm.SZ-1.5-abs(let-gm.SZ+0.5))
        firstnum = max(1, let-gm.SZ+1)
        for num in range(firstnum,firstnum+linelen):
            thislabel = letnum2lbl(let, num)
            # Convert index to label:
            gm.idx2lbl[idx] = thislabel
            # Convert index to (letter,number):
            gm.idx2letnum[idx] = (let, num)
            # Convert label to index:
            gm.lbl2idx[thislabel] = idx
            if (DEBUG): print(f'{gm.idx2lbl[idx]}')
            xdot, ydot = calc_xy(let, num)
            # Points:
            radius = 5
            cnv.create_oval(((xdot-radius,ydot-radius),(xdot+radius,ydot+radius)),
                            tags='board', outline='', fill='black')
            # Possible moves:
            cnv.create_image(xdot, ydot, image=cnv.pimg,  state='hidden',
                                        tags=(thislabel, 'board', 'poss'))
            # https://stackoverflow.com/questions/35111894
            tag = f'poss&&{thislabel}'
            cnv.tag_bind(tag, '<Button-1>', lambda _,lbl=thislabel:click_move(lbl))
            # Red halo (must be below the stones in the image stack):
            cnv.create_image(xdot, ydot, image=cnv.rhimg, state='hidden',
                             tags=(thislabel, 'board', 'halo'))
            # Stones:
            cnv.create_image(xdot, ydot, image=cnv.wimg,  state='hidden',
                             tags=(thislabel, 'board', COLORNAME[WHITE]))
            cnv.create_image(xdot, ydot, image=cnv.bimg,  state='hidden',
                             tags=(thislabel, 'board', COLORNAME[BLACK]))
            # Red dot marks last move:
            cnv.create_image(xdot, ydot, image=cnv.rdimg, state='hidden',
                             tags=(thislabel, 'board', 'reddot'))
            # Group numbers:
            cnv.create_text(xdot, ydot, state='hidden', fill='blue',
                            font=B20, tags=(thislabel, 'board', 'groupnumber'), text='')
            # Labels:
            cnv.create_text(xdot, ydot, state='hidden', fill='blue',
                            font=B15, tags=(thislabel, 'board', 'label'), text=thislabel)
            # Move numbers:
            cnv.create_text(xdot, ydot, state='hidden', fill='red',
                            font=B20, tags=(thislabel, 'board', 'movenum'), text='')
            # Define all (3, 4 or 6) neighbors:
            neighbors = []
            for direction in DIRECTIONS:
                let2 = let+direction[0]
                num2 = num+direction[1]
                if (is_valid(let2, num2)):
                    neighbors.append(letnum2lbl(let2, num2))
            if (DEBUG): print(f'{letnum2lbl(let,num)} -> {neighbors}')
            gm.neighbors[idx] = neighbors
            idx += 1
    # Draw coordinates for each letter:
    for let in range1(gm.Ncoord):
        firstnum = max(1, let-gm.SZ+1)
        cnv.create_line((calc_xy(let, firstnum-0.5),calc_xy(let, firstnum-0.8)),
                        tags=('coord', 'board'))
        cnv.create_text(calc_xy(let, firstnum-0.8), anchor='se', tags=('coord', 'board'),
                        font=B15, text=f'{chr(let+64)}')
    # Draw coordinates for each number:
    for num in range1(gm.Ncoord):
        firstlet = max(1, num-gm.SZ+1)
        cnv.create_line((calc_xy(firstlet-0.5, num),calc_xy(firstlet-0.8, num)),
                        tags=('coord', 'board'))
        cnv.create_text(calc_xy(firstlet-0.8, num), anchor='sw',
                        font=B15, tags=('coord', 'board'), text=f'{num}')
    # Draw text and buttons:
    y = gm.SZ*90
    cnv.create_text(30,  70,  anchor='w', font=B15, tags=('info', 'mvnum'),  text='')
    cnv.create_text(30, 100,  anchor='w', font=B15, tags=('info', 'thismv'), text='')
    cnv.create_text(30, 130,  anchor='w', font=B15, tags=('info', 'nxtcol'), text='THE START')
    cnv.create_text(30, y,    anchor='w', font=B15, tags='info',             text=f'{gm.DT}')
    cnv.create_text(30, y+30, anchor='w', font=B15, tags='info',           text=f'W: {gm.PW}')
    cnv.create_text(30, y+60, anchor='w', font=B15, tags='info',           text=f'B: {gm.PB}')
    # Create button which supports png transparency:
    # https://stackoverflow.com/questions/29857757
    # <Button-1>, <Button-2> and <Button-3> = left, middle, right mouse button
    # https://stackoverflow.com/questions/32289175
    # https://stackoverflow.com/questions/70996098
    x = WINDOW_WIDTH-250
    y = gm.SZ*90 + 60
    cnv.create_image(x,     y, tags=('button', 'homebtn'),
        image=cnv.homeimg, activeimage=cnv.homehimg, disabledimage=cnv.homegimg)
    cnv.create_image(x+50,  y, tags=('button', 'prevbtn'),
        image=cnv.previmg, activeimage=cnv.prevhimg, disabledimage=cnv.prevgimg)
    cnv.create_image(x+100, y, tags=('button', 'nextbtn'),
        image=cnv.nextimg, activeimage=cnv.nexthimg, disabledimage=cnv.nextgimg)
    cnv.create_image(x+150, y, tags=('button', 'lastbtn'),
        image=cnv.lastimg, activeimage=cnv.lasthimg, disabledimage=cnv.lastgimg)
    cnv.create_image(x,  y+60, tags=('button', 'btgbtn'), state='hidden',
        image=cnv.btgimg,  activeimage=cnv.btghimg) # btg = back to game
    # Create object to display work in progress (wip):
    cnv.create_text(x-15, y-50, anchor='w', tags=('info', 'wiptxt'), font=B15, text='')
    cnv.create_text(x+35, y+60, anchor='w', tags=('info', 'btgtxt'), font=B15, text='back to game')
    cnv.tag_bind('homebtn', '<Button-1>', first_move)
    cnv.tag_bind('prevbtn', '<Button-1>', prev_move)
    cnv.tag_bind('nextbtn', '<Button-1>', next_move)
    cnv.tag_bind('lastbtn', '<Button-1>', last_move)
    cnv.tag_bind('btgbtn',  '<Button-1>', backtogame)
    cnv_configure_buttons()

    show_possible_moves(WHITE)

def is_valid(let, num):
    return ((let>=1) and (let<=gm.Ncoord) and
            (num>=1) and (num<=gm.Ncoord) and (abs(num-let)<=gm.Ncoord/2))

def cnv_configure_buttons():
    # Not at the start?
    if (gm.movenumber>0):
        cnv.itemconfig('prevbtn', state='normal') # activate prev button
        cnv.itemconfig('homebtn', state='normal') # activate home button
    else:
        cnv.itemconfig('prevbtn', state='disabled') # deactivate prev button
        cnv.itemconfig('homebtn', state='disabled') # deactivate home button
    # Not in a variation and not at the end of the gane?
    if ((gm.variation==0) and (gm.movenumber<gm.Nmoves-1)):
        cnv.itemconfig('nextbtn', state='normal') # activate next button
        cnv.itemconfig('lastbtn', state='normal') # activate last button
    else:
        cnv.itemconfig('nextbtn', state='disabled') # deactivate next button
        cnv.itemconfig('lastbtn', state='disabled') # deactivate last button
    # Not in a variation?
    if (gm.variation==0):
        cnv.itemconfig('btgtxt',  state='hidden') # hide "back to game" text
        cnv.itemconfig('btgbtn',  state='hidden') # hide "back to game" button
    else:
        cnv.itemconfig('btgtxt',  state='normal') # show "back to game" text
        cnv.itemconfig('btgbtn',  state='normal') # show "back to game" button

def end_of_game_check():
    # Is there a winner?
    if (gm.movenumber>1):
        for color in (WHITE, BLACK):
            if (np.count_nonzero(gm.stone==color)==0):
                gm.endofgame = True
                gm.poss[:] = False
                othercolor = COLORNAME[WHITE+BLACK-color].upper()
                cnv.itemconfig('nxtcol', text=f'{othercolor} WINS')
                cnv.itemconfig('poss', state='hidden')

def cnv_initialize():
    cnv.pack()
    # Add some attributes with initial values:
    cnv.show_coord   = True  # show coordinates?
    cnv.show_groups  = False # show groups?
    cnv.show_labels  = False # show coordinates on stones?
    cnv.show_halo    = True  # show halo?
    cnv.show_numbers = False # show numbers on stones?
    cnv.show_poss    = True  # show possible moves?

    # Load pictures for canvas:
    cnv.rdimg    = ImageTk.PhotoImage(Image.open('reddot.png'))      # red dot
    cnv.pimg     = ImageTk.PhotoImage(Image.open('poss.png'))        # possible moves
    cnv.pwimg    = ImageTk.PhotoImage(Image.open('poss_w.png'))      # possible W move hover
    cnv.pbimg    = ImageTk.PhotoImage(Image.open('poss_b.png'))      # possible B move hover
    cnv.rhimg    = ImageTk.PhotoImage(Image.open('redhalo.png'))     # red halo
    cnv.wimg     = ImageTk.PhotoImage(Image.open('white_stone.png')) # white stone
    # based on https://commons.wikimedia.org/wiki/File:Nicolas_P._Rougier%27s_rendering_of_a_small_stone_sphere.png
    cnv.bimg     = ImageTk.PhotoImage(Image.open('black_stone.png')) # black stone
    # based on https://commons.wikimedia.org/wiki/File:Go_b.svg
    cnv.bgimg    = ImageTk.PhotoImage(Image.open('woodenplank.jpg')) # wooden board background
    # based on https://www.freepik.com/free-photo/wooden-plank-texture_1037168.htm
    # Normal buttons:
    cnv.nextimg  = ImageTk.PhotoImage(Image.open('next.png').resize((40,40))) # next
    cnv.previmg  = ImageTk.PhotoImage(Image.open('prev.png').resize((40,40))) # prev
    cnv.homeimg  = ImageTk.PhotoImage(Image.open('home.png').resize((40,40))) # home
    cnv.lastimg  = ImageTk.PhotoImage(Image.open('last.png').resize((40,40))) # last
    cnv.btgimg   = ImageTk.PhotoImage(Image.open('btg.png').resize((40,40)))  # back to game
    # Active buttons (h=hover):
    cnv.nexthimg = ImageTk.PhotoImage(Image.open('next_h.png').resize((40,40))) # next hover
    cnv.prevhimg = ImageTk.PhotoImage(Image.open('prev_h.png').resize((40,40))) # prev hover
    cnv.homehimg = ImageTk.PhotoImage(Image.open('home_h.png').resize((40,40))) # home hover
    cnv.lasthimg = ImageTk.PhotoImage(Image.open('last_h.png').resize((40,40))) # last hover
    cnv.btghimg  = ImageTk.PhotoImage(Image.open('btg_h.png').resize((40,40)))  # btg  hover
    # Disabled buttons (g=grey):
    cnv.nextgimg = ImageTk.PhotoImage(Image.open('next_g.png').resize((40,40))) # next grey
    cnv.prevgimg = ImageTk.PhotoImage(Image.open('prev_g.png').resize((40,40))) # prev grey
    cnv.homegimg = ImageTk.PhotoImage(Image.open('home_g.png').resize((40,40))) # home grey
    cnv.lastgimg = ImageTk.PhotoImage(Image.open('last_g.png').resize((40,40))) # last grey
    cnv.btggimg  = ImageTk.PhotoImage(Image.open('btg_g.png').resize((40,40)))  # btg  grey
    # Player buttons from https://freesvg.org/simple-player-icons-vector-illustration

    # Board as background image:
    cnv.create_image(WINDOW_WIDTH/2, WINDOW_HEIGHT/2, image=cnv.bgimg)

def popup_window(title, windowtext):
    popup = Toplevel()
    popup.title(title)
    popup.attributes('-topmost', True)
    Label(popup, text=windowtext, font=N15).pack(padx=15, pady=15)
    Button(popup, text='OK', font=B15, command=popup.destroy).pack(pady=15)

def load_game(filename=None):
    if (not filename):
        # https://docs.python.org/3/library/dialog.html
        filename = filedialog.askopenfilename(
            initialdir = 'sgf',
            filetypes  = [('sgf files', '*.sgf')],
            title      = 'Please select a *.sgf file:')
    if ((filename) and (os.path.isfile(filename))):
        gm.read_sgf_file(filename)
        gui.title(f'Meridians: {gm.PW} (W) vs {gm.PB} (B)')
        # Reset board, buttons and info text:
        cnv.delete('board')
        cnv.delete('button')
        cnv.delete('info')
        # Draw new board (size may be different), buttons and info text:
        cnv_draw_board()
    else:
        popup_window('File not found', 'No valid *.sgf file selected')

def import_bga_game_log(logfilename=None):
    # The logfile is a copy and paste from the "Game log" at BGA.
    if (not logfilename):
        # https://docs.python.org/3/library/dialog.html
        logfilename = filedialog.askopenfilename(
            filetypes = [('log files', '*.log')],
            title     = 'Please select a *.log file:')
    if ((not logfilename) or (not os.path.isfile(logfilename))):
        popup_window('File not found', 'No valid *.log file selected')
        return
    with open(f'{logfilename}', 'r') as f_in:
        filecontents = f_in.read()
    allmoves = re.findall(r'\b[A-z][0-9][0-9]?\b', filecontents)
    basename, _ = os.path.splitext(logfilename) # rm .log suffix
    sgffilename = f'{basename}.sgf'
    if (os.path.exists(sgffilename)):
        timestamp = datetime.now().strftime('%Y-%m-%d-%H:%M:%S')
        sgffilename = f'{basename}-{timestamp}.sgf'
    sgfheader = [
        '(;FF[4]',
        'GM[776]',        # sgf game number
        'CA[UTF-8]',
        'GN[https://boardgamearena.com/gamereview?table=?????????]', # BGA table number
        'PC[BGA: https://boardgamearena.com]',
        'SZ[7]',          # "6", "7" or "8"
        'RU[Pie?]',       # "Pie" or empty
        'PW[White]',      # name of white player
        'PB[Black]',      # name of black player
        'DT[2023-??-??]', # date in ISO format YYYY-MM-DD
        'RE[?+]']         # result "W+" or "B+"
    with open(sgffilename, 'w') as f_out:
        for line in sgfheader: # write header
            print(line, file=f_out)
        colorchar = 'W' # W makes the first move
        for move in allmoves: # write all moves
            print(f';{colorchar}[{move}]', file=f_out)
            colorchar = 'B' if colorchar=='W' else 'W' # alternate B and W
        print(')', file=f_out)
    load_game(sgffilename)
    popup_window('Import completed', 'The input file\n' +
                 f'{os.path.basename(logfilename)}\nfrom the Game Log has been\n' +
                 f'converted, and the sgf file\n{sgffilename}\nhas been created.')

def import_boardspace_game():
    # https://docs.python.org/3/library/dialog.html
    boardspacefilename = filedialog.askopenfilename(
        filetypes = [('sgf files', '*.sgf')],
        title     = 'Select *.sgf from boardspace:')
    if ((not boardspacefilename) or (not os.path.isfile(boardspacefilename))):
        popup_window('File not found', 'No valid *.sgf file selected')
        return
    allmoves = [''] # list of moves
    movenum  = 0
    pick = False
    swap = False
    with open(boardspacefilename, 'r') as f:
        filecontents = f.read().replace('\n', '').split(';')
    for entry in filecontents:
        if (' Pick'  in entry): pick = True
        if (' Swap ' in entry): swap = True
        result = re.search('Dropb *([A-z]) *(\d+)', entry)
        if (result):
            movenum += 1
            col = ord(result.group(1))-64 # column
            row = int(result.group(2))    # row
            color = get_color(movenum)
            if (col<SZ):
                let = (2*SZ-1)-row
                num = (2*SZ-1)-SZ-row+col
            else:
                let = (2*SZ-1)+SZ-row-col
                num = (2*SZ-1)-row
            thismove = f'{COLORCHAR[color]}[{letnum2lbl(let,num)}]'
            allmoves.append(thismove)
        else:
            # Read header data:
            GN = re.findall(r'\bGN\[([^]]*)\]', entry)
            if (GN):
                result = re.search('MR-([^-]+)-([^-]+)-(\d\d\d\d-\d\d-\d\d)-', GN[0])
                if (result):
                    PW = result.group(1) # white player
                    PB = result.group(2) # black player
                    DT = result.group(3) # date
            SU = re.findall(r'\bSU\[([^]]*)\]', entry)
            if (SU):
                result = re.search('meridians-([567])(p?) ', SU[0])
                if (result):
                    SZ = int(result.group(1))+1 # size 5,6,7->6,7,8
                    RU = 'Pie' if (result.group(2)=='p') else '' # pie rule?
    if (pick):
        popup_window('Import error', 'The input file\n' +
                     f'{os.path.basename(boardspacefilename)}\nfrom boardspace ' +
                     f'cannot be converted,\nbecause Undo via Pick is not implemented.')
        return
    if ((swap) and (RU=='Pie')):
        # Swap moves 1 and 2:
        move2 = allmoves[1][2:-1]
        move1 = allmoves[2][2:-1]
        allmoves[1] = f'W[{move1}]'
        allmoves[2] = f'B[{move2}]'
    # Write new *.sgf file:
    sgfheader = [
        '(;FF[4]',
        'GM[776]',   # sgf game number
        'CA[UTF-8]',
        'PC[https://boardspace.net]',
        f'SZ[{SZ}]', # size = 6
        f'RU[{RU}]', # "Pie" or empty
        f'PW[{PW}]', # name of white player
        f'PB[{PB}]', # name of black player
        f'DT[{DT}]', # date in ISO format YYYY-MM-DD
        'RE[?+]']    # result "W+" or "B+"
    sgffilename = f'meridians-{PW}-{PB}.sgf'
    with open(sgffilename, 'w') as f_out:
        for line in sgfheader: # write header
            print(line, file=f_out)
        for move in allmoves[1:]: # from move 1 to last move
            print(f';{move}', file=f_out)
        print(')', file=f_out)
    load_game(sgffilename)
    popup_window('Import completed', 'The input file\n' +
                 f'{os.path.basename(boardspacefilename)}\nfrom boardspace has been\n' +
                 f'converted, and the sgf file\n{sgffilename}\nhas been created.')

def gui_initialize():
    gui.title('Meridians')
    gui.geometry(f'{WINDOW_WIDTH}x{WINDOW_HEIGHT}') # window size
    # https://www.pythontutorial.net/tkinter/tkinter-event-binding
    gui.bind('<Key>',      key_pressed)
    gui.bind('<Right>',    next_move)  # go to next move
    gui.bind('<Button-3>', prev_move)  # go to previous move
    gui.bind('<Left>',     prev_move)  # go to previous move
    gui.bind('<Home>',     first_move) # go to first move (i.e., start of game)
    gui.bind('<End>',      last_move)  # go to last move  (i.e., end of game)

    # Create menu bar:
    # https://www.pythontutorial.net/tkinter/tkinter-menu/
    # If a func with a parameter is needed:
    # https://stackoverflow.com/questions/5767228
    # command=lambda:myfunc(inputparam) e.g.: command=lambda:gm.read_sgf_file('myfile.sgf'))
    menubar = Menu(gui)
    gui.config(menu=menubar)
    # File menu:
    file_menu = Menu(menubar, tearoff=False)
    file_menu.add_command(label='Load Game',                command=load_game)
    file_menu.add_command(label='Import Game Log from BGA', command=import_bga_game_log)
    file_menu.add_command(label='Import from Boardspace',   command=import_boardspace_game)
    file_menu.add_command(label='Quit', command=gui.destroy)
    menubar.add_cascade(label='File', menu=file_menu, underline=0)
    # View menu:
    view_menu = Menu(menubar, tearoff=False)
    view_menu.add_command(label='Toggle coordinates',    command=toggle_coordinates)    # c
    view_menu.add_command(label='Toggle groups',         command=toggle_groups)         # g
    view_menu.add_command(label='Toggle halo',           command=toggle_halo)           # h
    view_menu.add_command(label='Toggle stone labels',   command=toggle_stone_labels)   # l
    view_menu.add_command(label='Toggle move numbers',   command=toggle_move_numbers)   # n
    view_menu.add_command(label='Toggle possible moves', command=toggle_possible_moves) # p
    menubar.add_cascade(label='View', menu=view_menu, underline=0)
    # Help menu:
    help_menu = Menu(menubar, tearoff=False)
    help_menu.add_command(label='Rules', command=show_rules)
    help_menu.add_command(label='About', command=show_about)
    menubar.add_cascade(label='Help', menu=help_menu, underline=0)

def evaluate_command_line_argument():
    # The game is either supplied as a command-line parameter, or it is
    # taken from the default file 'meridians.sgf':
    if len(sys.argv) > 1:
        filename = sys.argv[1]
    else:
        filename = 'meridians.sgf'
    return filename

##############################################################################

if __name__ == "__main__":

    # Create an instance of tkinter window:
    gui = Tk()
    gui_initialize()

    # Create canvas:
    cnv = Canvas(gui, width=WINDOW_WIDTH, height=WINDOW_HEIGHT)
    cnv_initialize()

    # Create an instance of the Game class:
    gm = Game()

    # Load a game:
    filename = evaluate_command_line_argument()
    _, suffix = os.path.splitext(filename)
    # If the input file is a Game Log from BGA, convert it to sgf:
    if (suffix=='.log'):
        import_bga_game_log(filename)
    else:
        load_game(filename)

    # Main loop:
    gui.mainloop()

##############################################################################
