package featurecat.lizzie;

import com.google.gson.Gson;
import com.google.gson.GsonBuilder;
import com.google.gson.reflect.TypeToken;
import com.jcabi.manifests.Manifests;
import com.toomasr.sgf4j.Sgf;
import com.toomasr.sgf4j.parser.Game;
import com.toomasr.sgf4j.parser.GameNode;
import com.toomasr.sgf4j.parser.Util;
import featurecat.lizzie.analysis.GnuGoScoreEstimator;
import featurecat.lizzie.analysis.Leelaz;
import featurecat.lizzie.analysis.ScoreEstimator;
import featurecat.lizzie.analysis.ZenScoreEstimator;
import featurecat.lizzie.gui.*;
import featurecat.lizzie.rules.*;
import featurecat.lizzie.util.ThreadPoolUtil;
import org.apache.commons.collections4.CollectionUtils;
import org.apache.commons.lang3.StringUtils;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import org.jetbrains.annotations.NotNull;
import org.jfree.graphics2d.svg.SVGGraphics2D;

import javax.imageio.ImageIO;
import javax.swing.*;
import javax.swing.UIManager.LookAndFeelInfo;
import javax.swing.filechooser.FileNameExtensionFilter;
import java.awt.*;
import java.awt.datatransfer.Clipboard;
import java.awt.datatransfer.DataFlavor;
import java.awt.datatransfer.StringSelection;
import java.awt.datatransfer.Transferable;
import java.awt.event.HierarchyEvent;
import java.awt.event.HierarchyListener;
import java.awt.image.BufferedImage;
import java.io.*;
import java.lang.reflect.Type;
import java.nio.charset.Charset;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.*;
import java.util.List;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.stream.Collectors;

/**
 * Main class.
 */
public class Lizzie {
    static {
        // Make java.util.logging work with log4j
        System.setProperty("java.util.logging.manager", "org.apache.logging.log4j.jul.LogManager");
    }

    private static final Logger logger = LogManager.getLogger(Lizzie.class);
    private static final ResourceBundle resourceBundle = ResourceBundle.getBundle("featurecat.lizzie.i18n.GuiBundle");

    public static final String SETTING_FILE = "mylizzie.json";
    public static final Gson gson = new GsonBuilder().setPrettyPrinting().create();

    public static final CountDownLatch exitLatch = new CountDownLatch(1);
    private static int lizzieExitCode = 0;

    public static GtpConsoleDialog gtpConsole;
    public static LizzieFrame frame;
    public static JDialog analysisDialog;
    public static AnalysisFrame analysisFrame;
    public static Leelaz leelaz;
    public static Board board;
    public static OptionDialog optionDialog;
    public static OptionSetting optionSetting;
    public static WinrateHistogramDialog winrateHistogramDialog;
    public static ScheduledExecutorService miscExecutor = Executors.newSingleThreadScheduledExecutor();
    public static ScoreEstimator scoreEstimator = null;
    public static GameInfo gameInfo;

    static {
        readSettingFile();

        migrateSettings();

        // Sometimes gson will fail to parse the file
        if (optionSetting == null
                || optionSetting.getBoardColor() == null
                || StringUtils.isEmpty(optionSetting.getLeelazCommandLine())) {
            optionSetting = new OptionSetting();
        }
    }

    private static void migrateSettings() {
        if (isMigrationNeeded()) {
            List<String> engineProfileList = optionSetting.getEngineProfileList();
            String currentEngineProfile = optionSetting.getLeelazCommandLine();

            optionSetting.setEngineProfileList(engineProfileList.stream()
                    .map(Lizzie::migrateEngineProfile)
                    .collect(Collectors.toCollection(ArrayList::new)));
            optionSetting.setLeelazCommandLine(migrateEngineProfile(currentEngineProfile));
        }
    }

    private static String migrateEngineProfile(String oldProfile) {
        if (StringUtils.isEmpty(oldProfile)) {
            return oldProfile;
        } else {
            return "./leelaz " + oldProfile;
        }
    }

    private static boolean isMigrationNeeded() {
        try (Reader reader = new FileReader(SETTING_FILE)) {
            Type mapType = new TypeToken<LinkedHashMap<String, Object>>() {
            }.getType();
            LinkedHashMap<String, Object> rawSettings = gson.fromJson(reader, mapType);
            Integer version = (Integer) rawSettings.get("version");
            return version == null || version < new OptionSetting().getVersion();
        } catch (Exception e) {
            return false;
        }
    }

    public static void exitLizzie(int exitCode) {
        leelaz.setThinking(false);

        try {
            Thread.sleep(250);
        } catch (InterruptedException e) {
            // Do nothing
        }

        if (Lizzie.board.getHistory().getInitialNode().getNext() != null) {
            Lizzie.storeGameByFile(Paths.get("restore.sgf"));
        }

        Lizzie.leelaz.close();

        ThreadPoolUtil.shutdownAndAwaitTermination(Lizzie.miscExecutor);
        if (scoreEstimator != null) {
            try {
                scoreEstimator.close();
            } catch (Exception e) {
                e.printStackTrace();
            }
        }

        System.exit(exitCode);
    }

    public static void notifyExitLizzie(int exitCode) {
        lizzieExitCode = exitCode;

        // Main thread will exit
        exitLatch.countDown();
    }

    /**
     * Launches the game window, and runs the game.
     */
    public static void main(String[] args) {
        // Use system default look and feel
        try {
            UIManager.setLookAndFeel(UIManager.getSystemLookAndFeelClassName());
        } catch (Exception e1) {
            // Use Nimbus look and feel which looks better
            try {
                for (LookAndFeelInfo info : UIManager.getInstalledLookAndFeels()) {
                    if ("Nimbus".equals(info.getName())) {
                        UIManager.setLookAndFeel(info.getClassName());
                        break;
                    }
                }
            } catch (Exception e2) {
                // If Nimbus is not available, leave it for default
            }
        }

        gameInfo = new GameInfo();

        gtpConsole = new GtpConsoleDialog(null);
        optionSetting.getGtpConsoleWindowState().applyStateTo(gtpConsole);
        gtpConsole.setVisible(true);

        leelaz = new Leelaz(optionSetting.getLeelazCommandLine());
        board = new Board();
        leelaz.startEngine();
        board.linkBoardWithAnalyzeEngine();

        frame = new LizzieFrame();

        analysisDialog = AnalysisFrame.createAnalysisDialog(frame);
        analysisFrame = (AnalysisFrame) analysisDialog.getContentPane();

        optionDialog = new OptionDialog(frame);
        optionDialog.setDialogSetting(optionSetting);

        winrateHistogramDialog = new WinrateHistogramDialog(frame);

        setGuiPosition();

        analysisDialog.setVisible(optionSetting.isAnalysisWindowShow());
        winrateHistogramDialog.setVisible(optionSetting.isWinrateHistogramWindowShow());

        if (Files.exists(Paths.get("Zen.dll")) && Files.exists(Paths.get("YAZenGtp.exe"))) {
            scoreEstimator = new ZenScoreEstimator("YAZenGtp.exe");
        } else if (Files.exists(Paths.get("gnugo")) || Files.exists(Paths.get("gnugo.exe"))) {
            scoreEstimator = new GnuGoScoreEstimator("./gnugo --mode gtp");
        }

        try {
            leelaz.setThinking(true);
            gtpConsole.setVisible(optionSetting.isGtpConsoleWindowShow());
            exitLatch.await();
        } catch (InterruptedException e) {
            // Do nothing
        }

        exitLizzie(lizzieExitCode);
    }

    public static void clearBoardAndState() {
        // Should be before board.clear() because board.clear() will trigger some callbacks, and these callbacks
        // may try to get the komi settings.
        gameInfo.reset();
        board.clear();
    }

    public static void loadGameByPrompting() {
        FileNameExtensionFilter filter = new FileNameExtensionFilter("*.sgf", "SGF");
        final JFileChooser chooser = new JFileChooser(optionSetting.getLastChooserLocation());
        chooser.addChoosableFileFilter(filter);
        chooser.setMultiSelectionEnabled(false);
        chooser.setAcceptAllFileFilterUsed(false);

        setFileChooserAutoFocusOnTextField(chooser);

        int state = chooser.showOpenDialog(frame);
        if (state == JFileChooser.APPROVE_OPTION) {
            optionSetting.setLastChooserLocation(chooser.getSelectedFile().toPath().getParent().toString());

            File file = chooser.getSelectedFile();
            if (!file.getPath().toLowerCase().endsWith(".sgf")) {
                file = new File(file.getPath() + ".sgf");
            }

            loadGameByFile(file.toPath());
        }
    }

    private static void setFileChooserAutoFocusOnTextField(JFileChooser chooser) {
        chooser.addHierarchyListener(new HierarchyListener() {
            public void hierarchyChanged(HierarchyEvent he) {
                grabFocusForTextField(chooser.getComponents());
            }

            // Loop to find the JTextField, the first
            // JTextField in JFileChooser
            // Even if you setAccessory which contains a JTextField
            // or which is JTextField itself, it will not get focus
            private void grabFocusForTextField(Component[] components) {
                for (Component component : components) {
                    if (component instanceof JTextField) {
                        JTextField textField = (JTextField) component;
                        textField.grabFocus();
                        break;
                    } else if (component instanceof JPanel) {
                        JPanel panel = (JPanel) component;
                        grabFocusForTextField(panel.getComponents());
                    }
                }
            }
        });
    }

    public static void copyGameToClipboardInSgf() {
        try {
            Game game = snapshotCurrentGame();
            String sgfContent = writeSgfToString(game);

            Clipboard clipboard = Toolkit.getDefaultToolkit().getSystemClipboard();
            Transferable transferableString = new StringSelection(sgfContent);
            clipboard.setContents(transferableString, null);
        } catch (Exception e) {
            logger.error("Error in copying game to clipboard.");
        }
    }

    public static void pasteGameFromClipboardInSgf() {
        try {
            String sgfContent = null;
            // Read from clipboard
            Clipboard clipboard = Toolkit.getDefaultToolkit().getSystemClipboard();
            Transferable clipboardContents = clipboard.getContents(null);
            if (clipboardContents != null) {
                if (clipboardContents.isDataFlavorSupported(DataFlavor.stringFlavor)) {
                    sgfContent = (String) clipboardContents.getTransferData(DataFlavor.stringFlavor);
                }
            }

            if (StringUtils.isNotEmpty(sgfContent)) {
                Game game = Sgf.createFromString(sgfContent);
                loadGameToBoard(game);
            }
        } catch (Exception e) {
            logger.error("Error in copying game from clipboard.");
        }
    }

    public static void promptForChangeExistingMove() {
        ChangeMoveDialog changeMoveDialog = new ChangeMoveDialog(frame);
        changeMoveDialog.setVisible(true);
        if (changeMoveDialog.isUserApproved()) {
            int moveNumber = changeMoveDialog.getMoveNumber();
            String correctedMove = changeMoveDialog.getCorrectedMove().trim().toUpperCase();
            int[] convertedCoords = Board.convertDisplayNameToCoordinates(correctedMove);
            if (StringUtils.equalsIgnoreCase(correctedMove, "pass")) {
                Lizzie.miscExecutor.execute(() -> board.changeMove(moveNumber, (int[]) null));
            } else if (StringUtils.equalsIgnoreCase(correctedMove, "swap") || StringUtils.startsWithIgnoreCase(correctedMove, "trans")) {
                Lizzie.miscExecutor.execute(() -> board.swapMoveColor(moveNumber));
            } else if (Board.isValid(convertedCoords)) {
                Lizzie.miscExecutor.execute(() -> board.changeMove(moveNumber, convertedCoords));
            } else {
                JOptionPane.showMessageDialog(frame, resourceBundle.getString("Lizzie.prompt.invalidCoordinates"), "Lizzie", JOptionPane.ERROR_MESSAGE);
            }
        }
    }

    public static void switchEngineByProfileIndex(int profileIndex) {
        String newCommandLine = CollectionUtils.isEmpty(Lizzie.optionSetting.getEngineProfileList()) ? "" : Lizzie.optionSetting.getEngineProfileList().get(profileIndex);
        if (StringUtils.isNotEmpty(newCommandLine)) {
            Lizzie.optionSetting.setLeelazCommandLine(newCommandLine);

            switchEngineBySetting();
        }
    }

    public static void switchEngineBySetting() {
        final int moveNumber = board.getData().getMoveNumber();

        // Workaround for leelaz cannot exit when restarting
        leelaz.setThinking(false);

        board.resetHead();
        leelaz.restartEngine(Lizzie.optionSetting.getLeelazCommandLine());
        board.gotoMove(moveNumber);

        SwingUtilities.invokeLater(() -> frame.setEngineProfile(Lizzie.optionSetting.getLeelazCommandLine()));
    }

    private static class MoveReplayer {
        private boolean nextIsBlack;
        private int placedMoveCount;

        public MoveReplayer() {
            nextIsBlack = true;
            placedMoveCount = 0;
        }

        public void playMove(boolean isBlack, int x, int y) {
            if (nextIsBlack == isBlack) {
                Lizzie.board.place(x, y);
                nextIsBlack = !nextIsBlack;

                placedMoveCount += 1;
            } else {
                Lizzie.board.pass();
                Lizzie.board.place(x, y);

                placedMoveCount += 2;
            }
        }

        public int getPlacedMoveCount() {
            return placedMoveCount;
        }

        public void clearPlacedMoveCount () {
            placedMoveCount = 0;
        }
    }

    public static void loadGameByFile(Path gameFilePath) {
        try {
            Game game = Sgf.createFromPath(gameFilePath);
            loadGameToBoard(game);
        } catch (Exception e) {
            JOptionPane.showMessageDialog(frame, "Error: cannot load sgf: " + e.getMessage(), "Lizzie", JOptionPane.ERROR_MESSAGE);
        }
    }

    private static void loadGameToBoard(final Game game) {
        Lizzie.leelaz.batchGtpCommands(() -> {
            try {
                clearBoardAndState();

                GameNode node = game.getRootNode();
                MoveReplayer replayer = new MoveReplayer();

                // Process pre-placed stones
                placePreplacedMove(replayer, game.getProperty("AB"), game.getProperty("AW"));
                int preplacedStonesCount = replayer.getPlacedMoveCount();

                do {
                    String preplacedBlack = node.getProperty("AB");
                    String preplacedWhite = node.getProperty("AW");
                    if (StringUtils.isNotEmpty(preplacedBlack) || StringUtils.isNotEmpty(preplacedWhite)) {
                        placePreplacedMove(replayer, preplacedBlack, preplacedWhite);
                    }
                    if (node.isMove()) {
                        if (StringUtils.isNotEmpty(node.getProperty("B"))) {
                            int[] coords = node.getCoords();
                            if (coords != null && coords[0] < 19 && coords[0] >= 0 && coords[1] < 19 && coords[1] >= 0) {
                                replayer.playMove(true, coords[0], coords[1]);
                            }
                        }
                        if (StringUtils.isNotEmpty(node.getProperty("W"))) {
                            int[] coords = node.getCoords();
                            if (coords != null && coords[0] < 19 && coords[0] >= 0 && coords[1] < 19 && coords[1] >= 0) {
                                replayer.playMove(false, coords[0], coords[1]);
                            }
                        }
                    }
                }
                while ((node = node.getNextNode()) != null);

                gameInfo.setHiddenMoveCount(preplacedStonesCount);
            } catch (Exception e) {
                // Ignore
            }
        });
    }

    private static void placePreplacedMove(MoveReplayer replayer, String preplacedBlackStoneString, String preplacedWhiteStoneString) {
        List<int[]> preplacedBlackStones = Collections.emptyList(), preplacedWhiteStones = Collections.emptyList();
        if (StringUtils.isNotEmpty(preplacedBlackStoneString)) {
            preplacedBlackStones = Arrays.stream(preplacedBlackStoneString.split(","))
                    .map(String::trim)
                    .map(Util::alphaToCoords)
                    .collect(Collectors.toList());
        }
        if (StringUtils.isNotEmpty(preplacedWhiteStoneString)) {
            preplacedWhiteStones = Arrays.stream(preplacedWhiteStoneString.split(","))
                    .map(String::trim)
                    .map(Util::alphaToCoords)
                    .collect(Collectors.toList());
        }

        if (CollectionUtils.isNotEmpty(preplacedBlackStones) || CollectionUtils.isNotEmpty(preplacedWhiteStones)) {
            int maxLength = Math.max(preplacedBlackStones.size(), preplacedWhiteStones.size());
            for (int i = 0; i < maxLength; ++i) {
                if (i < preplacedBlackStones.size()) {
                    replayer.playMove(true, preplacedBlackStones.get(i)[0], preplacedBlackStones.get(i)[1]);
                }
                if (i < preplacedWhiteStones.size()) {
                    replayer.playMove(false, preplacedWhiteStones.get(i)[0], preplacedWhiteStones.get(i)[1]);
                }
            }
        }
    }

    public static void storeGameByFile(Path filePath) {
        try {
            Game game = snapshotCurrentGame();
            writeSgfToFile(game, filePath);
        } catch (Exception e) {
            if (StringUtils.isEmpty(e.getMessage())) {
                JOptionPane.showMessageDialog(frame, "Error: cannot save sgf: " + e.getMessage(), "Lizzie", JOptionPane.ERROR_MESSAGE);
            } else {
                JOptionPane.showMessageDialog(frame, "Error: cannot save sgf", "Lizzie", JOptionPane.ERROR_MESSAGE);
            }
        }
    }

    @NotNull
    private static Game snapshotCurrentGame() {
        final int BOARD_SIZE = Lizzie.optionSetting.getBoardSize().getWidth();
        Game game = new Game();

        game.addProperty("FF", "4"); // SGF version: 4
        game.addProperty("KM", String.valueOf(gameInfo.getKomi()));
        game.addProperty("GM", "1"); // Go game
        game.addProperty("SZ", String.valueOf(BOARD_SIZE));
        game.addProperty("CA", "UTF-8");
        game.addProperty("AP", "MyLizzie");

        BoardHistoryList historyList = board.getHistory();
        BoardHistoryNode initialNode = historyList.getInitialNode();

        GameNode previousSgfNode = null;
        BoardHistoryNode previousNode = null;
        for (BoardHistoryNode p = initialNode.getNext(); p != null; p = p.getNext()) {
            GameNode gameNode = new GameNode(previousSgfNode);

            // Move node
            if (Objects.equals(p.getData().getLastMoveColor(), Stone.BLACK) || Objects.equals(p.getData().getLastMoveColor(), Stone.WHITE)) {
                int x, y;

                if (p.getData().getLastMove() == null) {
                    // Pass
                    x = BOARD_SIZE;
                    y = BOARD_SIZE;
                } else {
                    x = p.getData().getLastMove()[0];
                    y = p.getData().getLastMove()[1];

                    if (x < 0 || x >= BOARD_SIZE || y < 0 || y >= BOARD_SIZE) {
                        x = BOARD_SIZE;
                        y = BOARD_SIZE;
                    }
                }

                String moveKey = Objects.equals(p.getData().getLastMoveColor(), Stone.BLACK) ? "B" : "W";
                String moveValue = Util.coordToAlpha.get(x) + Util.coordToAlpha.get(y);

                gameNode.addProperty(moveKey, moveValue);
                if (p.getData().getCalculationCount() > 100) {
                    gameNode.addProperty("C", String.format("Black: %.1f; White: %.1f", p.getData().getBlackWinrate(), p.getData().getWhiteWinrate()));
                }
            }

            if (p.getData().getMoveNumber() > 0) {
                gameNode.setMoveNo(p.getData().getMoveNumber());
            }

            if (previousSgfNode != null) {
                previousSgfNode.addChild(gameNode);
                // Ensure we have already added child. The previousNode is not null here.
                //addVariationTrees(previousSgfNode, previousNode.getData());
                addTryPlayTrees(previousSgfNode, previousNode);
            } else {
                game.setRootNode(gameNode);
            }

            previousSgfNode = gameNode;
            previousNode = p;
        }

        // Ignore the last node
        // addVariationTree(previousNode, previousData);

        return game;
    }

    private static void addTryPlayTrees(GameNode baseSgfNode, BoardHistoryNode baseNode) {
        if (CollectionUtils.isEmpty(baseNode.getTryPlayHistory())) {
            return;
        }

        for (BoardHistoryNode node : baseNode.getTryPlayHistory()) {
            addTryPlayTree(baseSgfNode, baseNode, node);
        }
    }

    private static void addTryPlayTree(GameNode baseSgfNode, BoardHistoryNode baseNode, BoardHistoryNode tryPlayBeginNode) {
        final int BOARD_SIZE = Lizzie.optionSetting.getBoardSize().getWidth();
        GameNode previousSgfNode = baseSgfNode;

        for (BoardHistoryNode p = tryPlayBeginNode; p != null; p = p.getNext()) {
            GameNode gameNode = new GameNode(previousSgfNode);

            // Move node
            if (Objects.equals(p.getData().getLastMoveColor(), Stone.BLACK) || Objects.equals(p.getData().getLastMoveColor(), Stone.WHITE)) {
                int x, y;

                if (p.getData().getLastMove() == null) {
                    // Pass
                    x = BOARD_SIZE;
                    y = BOARD_SIZE;
                } else {
                    x = p.getData().getLastMove()[0];
                    y = p.getData().getLastMove()[1];

                    if (x < 0 || x >= BOARD_SIZE || y < 0 || y >= BOARD_SIZE) {
                        x = BOARD_SIZE;
                        y = BOARD_SIZE;
                    }
                }

                String moveKey = Objects.equals(p.getData().getLastMoveColor(), Stone.BLACK) ? "B" : "W";
                String moveValue = Util.coordToAlpha.get(x) + Util.coordToAlpha.get(y);

                gameNode.addProperty(moveKey, moveValue);
                if (p.getData().getCalculationCount() > 100) {
                    gameNode.addProperty("C", String.format("Black: %.1f; White: %.1f", p.getData().getBlackWinrate(), p.getData().getWhiteWinrate()));
                }
            }

            if (p.getData().getMoveNumber() > 0) {
                gameNode.setMoveNo(p.getData().getMoveNumber());
            }

            if (previousSgfNode != null) {
                previousSgfNode.addChild(gameNode);
            }

            previousSgfNode = gameNode;
        }
    }

    private static void addVariationTrees(GameNode baseNode, BoardData data) {
        if (CollectionUtils.isEmpty(data.getVariationDataList())) {
            return;
        }

        int treeCount = 0;
        for (VariationData variationData : data.getVariationDataList()) {
            // We only add variation whose playouts is greater than 200
            if (variationData.getPlayouts() > 200) {
                addVariationTree(baseNode, variationData);

                ++treeCount;
                // We only care for 5 or less variations
                if (treeCount > 5) {
                    break;
                }
            }
        }
    }

    private static void addVariationTree(GameNode baseNode, VariationData variationData) {
        final int BOARD_SIZE = Lizzie.optionSetting.getBoardSize().getWidth();
        Stone baseColor = baseNode.isBlack() ? Stone.BLACK : Stone.WHITE;
        GameNode previousNode = baseNode;

        int variationMoveCount = 0;
        for (int[] variation : variationData.getVariation()) {
            GameNode gameNode = new GameNode(previousNode);

            int x, y;
            if (variation == null) {
                // Pass
                x = BOARD_SIZE;
                y = BOARD_SIZE;
            } else {
                x = variation[0];
                y = variation[1];

                if (x < 0 || x >= BOARD_SIZE || y < 0 || y >= BOARD_SIZE) {
                    x = BOARD_SIZE;
                    y = BOARD_SIZE;
                }
            }

            String moveKey = Objects.equals(baseColor.opposite(), Stone.BLACK) ? "B" : "W";
            String moveValue = Util.coordToAlpha.get(x) + Util.coordToAlpha.get(y);
            gameNode.addProperty(moveKey, moveValue);

            if (previousNode == baseNode && variationData.getPlayouts() > 100) {
                double blackWinrate, whiteWinrate;
                if (moveKey.equals("B")) {
                    blackWinrate = variationData.getWinrate();
                    whiteWinrate = 100 - blackWinrate;
                } else {
                    whiteWinrate = variationData.getWinrate();
                    blackWinrate = 100 - whiteWinrate;
                }

                gameNode.addProperty("C", String.format("Black: %.1f; White: %.1f", blackWinrate, whiteWinrate));
            }

            previousNode.addChild(gameNode);

            previousNode = gameNode;
            baseColor = baseColor.opposite();

            ++variationMoveCount;
            if (variationMoveCount >= Lizzie.optionSetting.getVariationLimit()) {
                break;
            }
        }
    }

    public static String writeSgfToString(Game game) {
        try (StringWriter writer = new StringWriter()) {
            writeSgfToStream(game, writer);
            return writer.toString();
        } catch (IOException e) {
            throw new RuntimeException(e);
        }
    }

    public static void writeSgfToFile(Game game, Path destination) {
        try (OutputStreamWriter writer = new OutputStreamWriter(new FileOutputStream(destination.toFile()), Charset.forName("UTF-8"))) {
            writeSgfToStream(game, writer);
        } catch (IOException e) {
            throw new RuntimeException(e);
        }
    }

    private static void writeSgfToStream(Game game, Writer writer) throws IOException {
        writer.write('(');

        // lets write all the root node properties
        Map<String, String> props = game.getProperties();
        if (props.size() > 0) {
            writer.write(";");
        }

        for (Map.Entry<String, String> entry : props.entrySet()) {
            writer.write(entry.getKey());
            writer.write('[');
            writer.write(entry.getValue());
            writer.write(']');
        }

        // write sgf nodes
        GameNode node = game.getRootNode();
        writeSgfSubTree(writer, node, 0);
        writer.write(')');
    }

    private static void writeSgfSubTree(Writer writer, GameNode subtreeRoot, int level) throws IOException {
        if (level > 0) {
            writer.write('(');
        }

        // DF pre-order traversal
        // Write root
        writer.write(';');
        writeNodeProperties(writer, subtreeRoot);

        // Write children
        GameNode mainChild;
        if ((mainChild = subtreeRoot.getNextNode()) != null) {
            writeSgfSubTree(writer, mainChild, level + 1);

            for (GameNode otherChild : subtreeRoot.getChildren()) {
                writeSgfSubTree(writer, otherChild, level + 1);
            }
        }

        if (level > 0) {
            writer.write(')');
        }
    }

    private static void writeNodeProperties(Writer writer, GameNode node) throws IOException {
        for (Map.Entry<String, String> entry : node.getProperties().entrySet()) {
            writer.write(entry.getKey());
            writer.write('[');
            writer.write(entry.getValue());
            writer.write(']');
        }
    }

    private static void storeBoardByFile(Path filePath) {
        BufferedImage bufferedImage = Lizzie.frame.getCachedImage();
        SVGGraphics2D svgGraphics2D = new SVGGraphics2D(bufferedImage.getWidth(), bufferedImage.getHeight());
        try {
            svgGraphics2D.drawImage(bufferedImage, 0, 0, null);
            String fileContent = svgGraphics2D.getSVGDocument();
            try (FileWriter writer = new FileWriter(filePath.toFile())) {
                writer.write(fileContent);
            } catch (Exception e) {
                if (StringUtils.isEmpty(e.getMessage())) {
                    JOptionPane.showMessageDialog(frame, "Error: cannot save svg: " + e.getMessage(), "Lizzie", JOptionPane.ERROR_MESSAGE);
                } else {
                    JOptionPane.showMessageDialog(frame, "Error: cannot save svg", "Lizzie", JOptionPane.ERROR_MESSAGE);
                }
            }
        } finally {
            svgGraphics2D.dispose();
        }
    }

    private static void storeBoardPngImageByFile(Path filePath) {
        BufferedImage bufferedImage = Lizzie.frame.getCachedImage();
        try (FileOutputStream stream = new FileOutputStream(filePath.toFile())) {
            ImageIO.write(bufferedImage, "PNG", stream);
        } catch (Exception e) {
            if (StringUtils.isEmpty(e.getMessage())) {
                JOptionPane.showMessageDialog(frame, "Error: cannot save png: " + e.getMessage(), "Lizzie", JOptionPane.ERROR_MESSAGE);
            } else {
                JOptionPane.showMessageDialog(frame, "Error: cannot save png", "Lizzie", JOptionPane.ERROR_MESSAGE);
            }
        }
    }

    public static void storeGameByPrompting() {
        FileNameExtensionFilter sgfFilter = new FileNameExtensionFilter("*.sgf", "SGF");
        FileNameExtensionFilter svgFilter = new FileNameExtensionFilter("*.svg", "SVG");
        FileNameExtensionFilter pngFilter = new FileNameExtensionFilter("*.png", "PNG");

        JFileChooser chooser = new JFileChooser(optionSetting.getLastChooserLocation());
        chooser.setAcceptAllFileFilterUsed(false);
        chooser.addChoosableFileFilter(sgfFilter);
        chooser.addChoosableFileFilter(svgFilter);
        chooser.addChoosableFileFilter(pngFilter);
        chooser.setMultiSelectionEnabled(false);

        setFileChooserAutoFocusOnTextField(chooser);

        int result = chooser.showSaveDialog(frame);
        if (result == JFileChooser.APPROVE_OPTION) {
            File file = chooser.getSelectedFile();
            optionSetting.setLastChooserLocation(file.getParent());

            if (!file.getPath().toLowerCase().endsWith(".sgf") && !file.getPath().toLowerCase().endsWith(".svg") && !file.getPath().toLowerCase().endsWith(".png")) {
                if (chooser.getFileFilter().equals(sgfFilter)) {
                    file = new File(file.getPath() + ".sgf");
                } else if (chooser.getFileFilter().equals(svgFilter)) {
                    file = new File(file.getPath() + ".svg");
                } else {
                    file = new File(file.getPath() + ".png");
                }
            }

            if (file.exists()) {
                int ret = JOptionPane.showConfirmDialog(frame, "The target file is exists, do you want replace it?", "Warning", JOptionPane.OK_CANCEL_OPTION);
                if (ret == JOptionPane.CANCEL_OPTION) {
                    return;
                }
            }

            if (file.getPath().toLowerCase().endsWith(".sgf")) {
                storeGameByFile(file.toPath());
            } else if (file.getPath().toLowerCase().endsWith(".svg")) {
                storeBoardByFile(file.toPath());
            } else {
                storeBoardPngImageByFile(file.toPath());
            }
        }
    }

    public static void readGuiPosition() {
        optionSetting.setMainWindowState(frame);
        optionSetting.setAnalysisWindowState(analysisDialog);
        optionSetting.setWinrateHistogramWindowState(winrateHistogramDialog);
        optionSetting.setGtpConsoleWindowState(gtpConsole);
    }

    public static void setGuiPosition() {
        SwingUtilities.invokeLater(() -> {
            optionSetting.getMainWindowState().applyStateTo(frame);
            optionSetting.getAnalysisWindowState().applyStateTo(analysisDialog);
            optionSetting.getWinrateHistogramWindowState().applyStateTo(winrateHistogramDialog);
            optionSetting.getGtpConsoleWindowState().applyStateTo(gtpConsole);
        });
    }

    public static void readSettingFile() {
        try (Reader reader = new FileReader(SETTING_FILE)) {
            optionSetting = gson.fromJson(reader, OptionSetting.class);
        } catch (FileNotFoundException e) {
            // Do nothing
        } catch (Exception e) {
            logger.error("Error in reading setting file.", e);
        }
    }

    public static void writeSettingFile() {
        try (Writer writer = new FileWriter(SETTING_FILE)) {
            writer.write(gson.toJson(optionSetting));
        } catch (Exception e) {
            logger.error("Error in writing setting file.", e);
        }
    }

    public static String getLizzieVersion() {
        if (Manifests.exists("Lizzie-Version")) {
            return Manifests.read("Lizzie-Version");
        } else {
            return null;
        }
    }
}