/*
Copyright (C) 2005 David Green <green@couchpotato.net>
All Rights Reserved.

This file is part of Aelfengard.

Aelfengard is proprietary software. You may not redistribute it without
prior written permission from the copyright holder.
*/

package common.ui;

import java.awt.BorderLayout;
import java.awt.Color;
import java.awt.Dimension;
import java.awt.FlowLayout;
import java.awt.Font;
import java.awt.Frame;
import java.awt.GridLayout;
import java.awt.Point;
import java.awt.event.ActionEvent;
import java.awt.event.ActionListener;
import java.awt.event.KeyAdapter;
import java.awt.event.KeyEvent;
import java.io.ByteArrayOutputStream;
import java.io.DataInput;
import java.io.DataInputStream;
import java.io.DataOutput;
import java.io.DataOutputStream;
import java.io.EOFException;
import java.io.File;
import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.PrintWriter;
import java.io.StringWriter;
import java.lang.ref.WeakReference;
import java.net.ConnectException;
import java.net.MalformedURLException;
import java.net.Socket;
import java.net.URL;
import java.net.URLConnection;
import java.security.KeyFactory;
import java.security.KeyPair;
import java.security.KeyPairGenerator;
import java.security.PublicKey;
import java.security.spec.X509EncodedKeySpec;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.Iterator;
import java.util.LinkedList;
import java.util.List;

import javax.crypto.Cipher;
import javax.crypto.KeyAgreement;
import javax.crypto.SecretKey;
import javax.crypto.spec.IvParameterSpec;
import javax.jnlp.FileContents;
import javax.jnlp.FileOpenService;
import javax.jnlp.ServiceManager;
import javax.jnlp.UnavailableServiceException;
import javax.swing.AbstractListModel;
import javax.swing.ImageIcon;
import javax.swing.JButton;
import javax.swing.JCheckBoxMenuItem;
import javax.swing.JDialog;
import javax.swing.JFileChooser;
import javax.swing.JFrame;
import javax.swing.JLabel;
import javax.swing.JLayeredPane;
import javax.swing.JList;
import javax.swing.JMenu;
import javax.swing.JMenuBar;
import javax.swing.JMenuItem;
import javax.swing.JOptionPane;
import javax.swing.JPanel;
import javax.swing.JPasswordField;
import javax.swing.JProgressBar;
import javax.swing.JScrollPane;
import javax.swing.JSplitPane;
import javax.swing.ScrollPaneConstants;
import javax.swing.SwingConstants;
import javax.swing.SwingUtilities;
import javax.swing.Timer;
import javax.swing.border.BevelBorder;
import javax.swing.border.Border;
import javax.swing.border.CompoundBorder;
import javax.swing.border.EmptyBorder;
import javax.swing.border.LineBorder;
import javax.swing.filechooser.FileFilter;

import common.Face;
import common.PubGroupMember;
import common.PubRoom;
import common.PubRoomEntity;
import common.ScriptType;
import common.TextEditorType;
import common.clientevent.ClientEvent;
import common.clientevent.CommandClientEvent;
import common.clientevent.ExecuteManualScriptClientEvent;
import common.clientevent.ImageUploadClientEvent;
import common.clientevent.SaveScriptClientEvent;
import common.gameevent.GameEvent;
import common.gameevent.StatGameEvent.StatType;
import common.gameevent.StatGameEvent.TableType;

/**
 * This is the main class that clients launch to play the game.
 * 
 * @author David Green <green@couchpotato.net>
 */
public class GameClient {

    private static final String DEFAULT_WINDOW_TITLE = "Aelfengard";
    
    // The field where text is typed in. Usually not in "password mode", but
    // we use a password field so that we can toggle it into password mode.
    private static JPasswordField chatField;

    // Since the chatField starts in password mode, we'll have this set
    // to true to start off with. When we draw the UI, we'll turn
    // password mode off immediately, and this field will be set to false
    // at that time.
    private static boolean passwordMode = true;

    // The main client game window. We use an actual JFrame rather than
    // subclassing it.
    private static JFrame mainFrame;

    // The "map" component that players see as their map.
    private static GameMap map;
    
    // The command history.
    private static HistoryListModel historyListModel = new HistoryListModel();
    
    // The "recent commands" component that players see.
    private static JList commandList = new JList(historyListModel);

    // The visual component that displays the room name above the map.
    private static JLabel roomLabel;

    // The room list visual component.
    private static EntityList<PubRoomEntity> roomEntityList = 
            new EntityList<PubRoomEntity>("Current Room");
    
    // The group list visual component.
    private static EntityList<PubGroupMember> groupList = 
            new EntityList<PubGroupMember>("Current Group");
    
    // A panel that contains the chatField(CENTER) and the gameModeLabel(WEST)
    private static JPanel chatPanel;
    
    // The visual component that displays all of the output from the game.
    private static DisplayPanel displayPanel = new DisplayPanel();

    // A large label used for displaying the image for the current room.
    private static JLabel roomImageLabel;
    
    // The loading bar that displays when a new room image is loading.
    private static JProgressBar loadingBar;
    
    // Used for displaying the "loading" animation over the room image.
    private static JPanel imageOverlayPanel;
    
    // Visual component that displays the game mode (TEST SERVER, ECLIPSE AUX, etc).
    private static JLabel gameModeLabel = new JLabel();

    // Check box menu item to choose whether to unminimize window on game output.
    private static JCheckBoxMenuItem restoreItem;

    // synchronize on this before accessing the write queue.
    private static final Object writeQueueLock = new Object();
    
    private static final Object imageCacheLock = new Object();

    // ClientEvents are added to this queue. The writer thread watches this
    // queue and actually sends them.
    private static ArrayList<ClientEvent> writeQueue = new ArrayList<ClientEvent>();

    // Once login is complete, this is set, which enables the numpad for movement,
    // and possibly other features.
    private static boolean loginComplete;
    
    // Number of concurrent image loads for room image. Used to determine
    // when the loading animation should be removed.
    private static int roomImageLoadCount = 0;
    
    // The word "AUX" or "MAIN" when running in development mode (-gui parameter).
    private static String extraModeInfo;
    
    // Current game mode "TEST SERVER", "ECLIPSE", etc.
    private static String currentGameMode;
    
    // Base URL to use for finding images.
    private static String currentBaseURL;
    
    // For encrypting data before sending to the server.
    private static Cipher encipher;
    
    // For decrypting data received from the server.
    private static Cipher decipher;
    
    // The list of open scripting consoles
    private static List<WeakReference<ConsoleWindow>> consoles = new LinkedList<WeakReference<ConsoleWindow>>();
    private static int nextConsoleId;

    public static void main(String... args) {
        try {
            int port = 443;
            if (args.length < 1 || args.length > 2) {
                System.err.println("Usage: java -jar client.jar HOSTNAME [PORT]");
                return;
            }
            String host = args[0];
            if (args.length == 2) {
                port = Integer.parseInt(args[1]);
            }
            makeUI(); // Sets up the main window's visual components
            displayPanel.print("Connecting to " + host + "...");
            Socket socket = new Socket(host, port);
            println(" @2ZZConnected!");
            // TODO: Use buffered output streams, make sure flushing is done
            DataInputStream in = new DataInputStream(socket.getInputStream());
            DataOutputStream out = new DataOutputStream(socket.getOutputStream());
            // Use DH key agreement to make an encipher and decipher
            negotiateEncryption(in, out);
            println("");
            
            // Start a writer thread to watch the write queue.
            new WriterThread(out).start();
            
            // And start a loop to wait for data from the server.
            while (true) {
                // read the next packet from the server
                int length = in.readInt();
                byte[] b = new byte[length];
                in.readFully(b);
                // decrypt the packet
                b = decipher.doFinal(b);
                // deserialize it into a game event
                final GameEvent evt = GameEvent.forByteArray(b);
                // and execute the event
                SwingUtilities.invokeLater(new Runnable() {
                    public void run() {
                        evt.run();
                    }
                });
                // rinse and repeat
            }
        } catch (ConnectException ex) {
            ex.printStackTrace();
            println("Couldn't connect to host: " + ex.getMessage());
        } catch (EOFException ex) {
            ex.printStackTrace();
            try { Thread.sleep(1000); } catch (Exception ex2) { /* ignore */ }
            System.exit(0);
        } catch (Throwable t) {
            t.printStackTrace();
            println(getStackTrace(t));
        }
    }

    /**
     * Initializes the main window by arranging all of the visual components.
     * @throws Exception if a problem occurs
     */
    private static void makeUI() throws Exception {
        // TODO: Add some explanation comments to this method's body.
        mainFrame = new JFrame(DEFAULT_WINDOW_TITLE);
        mainFrame.setJMenuBar(makeMenuBar());
        
        ClientUtils.initWindow(mainFrame, 0.80, 0.80);

        roomLabel = new JLabel();

        map = new GameMap();
        JScrollPane mapScroll = new JScrollPane(map);
        
        commandList.setForeground(Color.white);
        commandList.setBackground(Color.black);

        JLayeredPane roomImagePanel = new JLayeredPane();
        roomImagePanel.setPreferredSize(new Dimension(350, 250));
        roomImagePanel.setBackground(Color.white);
        
        roomImageLabel = new JLabel();
        
        imageOverlayPanel = new JPanel(new BorderLayout());
        roomImagePanel.add(roomImageLabel, new Integer(0));
        roomImagePanel.add(imageOverlayPanel, new Integer(1));

        JPanel loadingParent = new JPanel(new BorderLayout());
        JPanel loadingPanel = new JPanel(new BorderLayout(10, 10));
        JLabel loadingLabel = new JLabel("Loading image");
        loadingBar = new JProgressBar();
        loadingBar.setBorder(new CompoundBorder(new EmptyBorder(10, 10, 10, 10), loadingBar.getBorder()));
        loadingBar.setOpaque(false);
        loadingPanel.add(loadingLabel, BorderLayout.WEST);
        loadingPanel.add(loadingBar, BorderLayout.CENTER);
        loadingPanel.setBorder(new CompoundBorder(new LineBorder(Color.black, 1), new EmptyBorder(0, 5, 0, 5)));
        loadingPanel.setBackground(Color.white);
        loadingPanel.setOpaque(true);
        loadingParent.add(loadingPanel, BorderLayout.CENTER);
        loadingParent.setBorder(new EmptyBorder(10, 10, 10, 10));
        loadingParent.setOpaque(false);
        imageOverlayPanel.add(loadingParent, BorderLayout.SOUTH);

        imageOverlayPanel.setSize(new Dimension(350, 250));
        imageOverlayPanel.setLocation(0, 0);
        imageOverlayPanel.setOpaque(false);
        
        imageOverlayPanel.setVisible(false);
        
        chatField = new JPasswordField();
        setPasswordMode(false);
        chatField.setFont(chatField.getFont().deriveFont(Font.BOLD));

        // Arrange the layout
        chatPanel = new JPanel(new BorderLayout());

        JPanel mapPanel = new JPanel(new BorderLayout());
        
        gameModeLabel.setForeground(Color.red);

        JPanel gameModePanel = new JPanel(new BorderLayout());
        gameModePanel.add(gameModeLabel, BorderLayout.WEST);
        gameModePanel.add(chatField, BorderLayout.CENTER);
        
        chatPanel.add(gameModePanel, BorderLayout.SOUTH);
        chatPanel.add(displayPanel, BorderLayout.CENTER);
        final JSplitPane mainSplit = new JSplitPane(
                JSplitPane.HORIZONTAL_SPLIT, mapPanel, chatPanel);
        final JSplitPane topSplit = new JSplitPane(JSplitPane.HORIZONTAL_SPLIT,
                roomEntityList, groupList);
        mainSplit.setResizeWeight(0.3);
        topSplit.setResizeWeight(0.5);
        JPanel topPanel = new JPanel();
        topPanel.setLayout(new BorderLayout());
        topPanel.add(roomImagePanel, BorderLayout.WEST);
        topPanel.add(topSplit, BorderLayout.CENTER);
        mainFrame.add(mainSplit, BorderLayout.CENTER);
        mainFrame.add(topPanel, BorderLayout.NORTH);

        JLabel commandListLabel = new JLabel("=== Recent commands ===");
        commandListLabel.setBackground(Color.black);
        commandListLabel.setForeground(Color.white);
        commandListLabel.setOpaque(true);
        Font font = commandListLabel.getFont();
        font = font.deriveFont(Font.BOLD, 12.0f);
        commandListLabel.setFont(font);
        commandListLabel.setHorizontalAlignment(SwingConstants.CENTER);
        
        JScrollPane commandScroll = new JScrollPane(commandList);
        commandScroll.setHorizontalScrollBarPolicy(ScrollPaneConstants.HORIZONTAL_SCROLLBAR_NEVER);
        commandScroll.setPreferredSize(new Dimension(1, 80));
        commandScroll.setAutoscrolls(true);
        
        JPanel commandListPanel = new JPanel(new BorderLayout());
        commandListPanel.add(commandListLabel, BorderLayout.NORTH);
        commandListPanel.add(commandScroll, BorderLayout.CENTER);
        
        mapPanel.add(roomLabel, BorderLayout.NORTH);
        mapPanel.add(mapScroll, BorderLayout.CENTER);
        mapPanel.add(commandListPanel, BorderLayout.SOUTH);

        chatField.addActionListener(new ActionListener() {
            public void actionPerformed(ActionEvent e) {
                char[] password = chatField.getPassword();
                String cmd = String.valueOf(password);
                if (passwordMode) {
                    historyListModel.add("( password )");
                    chatField.setText("");
                } else {
                    chatField.setSelectionStart(0);
                    chatField.setSelectionEnd(cmd.length());
                    historyListModel.add(cmd);
                }
                sendCommand(cmd);
            }
        });
        chatField.addKeyListener(new KeyAdapter() {

            private int location = KeyEvent.KEY_LOCATION_UNKNOWN;

            @Override
            public void keyTyped(KeyEvent e) {
                if (passwordMode) {
                    return;
                }
                if (location != KeyEvent.KEY_LOCATION_NUMPAD) {
                    return;
                }
                if (!getLoginComplete()) {
                    return;
                }
                e.consume();
                String cmd = null;
                switch (e.getKeyChar()) {
                case '1':
                    cmd = "southwest";
                    break;
                case '2':
                    cmd = "south";
                    break;
                case '3':
                    cmd = "southeast";
                    break;
                case '4':
                    cmd = "west";
                    break;
                case '5':
                    cmd = "look";
                    map.centerCurrentRoom();
                    break;
                case '6':
                    cmd = "east";
                    break;
                case '7':
                    cmd = "northwest";
                    break;
                case '8':
                    cmd = "north";
                    break;
                case '9':
                    cmd = "northeast";
                    break;
                default:
                    map.centerCurrentRoom();
                }
                if (cmd != null) {
                    historyListModel.add(cmd);
                    sendCommand(cmd);
                }
            }

            @Override
            public void keyPressed(KeyEvent e) {
                if (passwordMode) {
                    return;
                }
                location = e.getKeyLocation();
                if (e.getKeyCode() == KeyEvent.VK_UP || e.getKeyCode() == KeyEvent.VK_DOWN) {
                    String s = historyListModel.handleScroll(e.getKeyCode() == KeyEvent.VK_UP, String.valueOf(chatField.getPassword()));
                    chatField.setText(s);
                    chatField.setSelectionStart(0);
                    chatField.setSelectionEnd(s.length());
                }
            }
        });

        displayPanel.setOnText(new Runnable() {
            public void run() {
                if ((mainFrame.getExtendedState() & Frame.ICONIFIED) != 0) {
                    if (restoreItem.isSelected()) {
                        mainFrame.setExtendedState(mainFrame.getExtendedState()
                                & ~Frame.ICONIFIED);
                    }
                    else {
                        // blink in taskbar in windows
                        mainFrame.toFront();
                    }
                }
            }
        });
        
        mainFrame.setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE);
        mainFrame.setVisible(true);
        SwingUtilities.invokeAndWait(new Runnable() {
            public void run() {
                mainFrame.setExtendedState(Frame.MAXIMIZED_BOTH);
                mainSplit.setDividerLocation(0.3);
                topSplit.setDividerLocation(0.5);
                chatField.requestFocusInWindow();
            }
        });
    }

    /**
     * This thread watches the write queue for new client events,
     * removes them from the queue, serializes them, encrypts them,
     * and sends them to the server.  
     */
    private static class WriterThread extends Thread {

        private final DataOutputStream out;

        public WriterThread(DataOutputStream out) {
            this.out = out;
        }

        @Override
        public void run() {
            try {
                long nextKeepAlive = 0;
                long timeout;
                while (true) {
                    ArrayList<ClientEvent> localQueue;
                    synchronized (writeQueueLock) {
                        while (true) {
                            timeout = nextKeepAlive
                                    - System.currentTimeMillis();
                            if (timeout <= 0 || writeQueue.size() > 0) {
                                break;
                            }
                            // wait until a client event is added to the queue,
                            // or until it's time to send a keep alive packet
                            writeQueueLock.wait(timeout);
                        }
                        localQueue = writeQueue;
                        writeQueue = new ArrayList<ClientEvent>();
                    }
                    if (timeout <= 0) {
                        // enqueue a keepalive packet
                        nextKeepAlive = System.currentTimeMillis() + 15000;
                        synchronized (GameClient.class) {
                            if (loginComplete) {
                                localQueue.add(new CommandClientEvent(
                                        "keepalive"));
                            }
                        }
                    }
                    // process the write queue
                    for (ClientEvent evt : localQueue) {
                        // serialize the client event
                        byte[] b = evt.toByteArray();
                        // encrypt it
                        b = encipher.doFinal(b);
                        // and send it to the server
                        out.writeInt(b.length);
                        out.write(b);
                    }
                    out.flush();
                }
            } catch (Throwable t) {
                t.printStackTrace();
            } finally {
                System.exit(-1);
            }
        }
    }

    /**
     * Returns the GameMap.
     * @return the GameMap
     */
    public static GameMap getMap() {
        return map;
    }

    /**
     * Sets the room name displayed over the map.
     * @param name the new room name
     */
    public static void setRoomName(String name) {
        roomLabel.setText(name);
    }

    /**
     * Sends the specified client event to the server.
     * @param evt the client event to send
     */
    public static void send(ClientEvent evt) {
        synchronized (writeQueueLock) {
            writeQueue.add(evt);
            writeQueueLock.notifyAll();
        }
    }

    /**
     * Extracts a 'stack trace string' from the specified throwable.
     * @param t the throwable to analyze
     * @return the stack trace string
     */
    private static String getStackTrace(Throwable t) {
        StringWriter sw = new StringWriter();
        t.printStackTrace(new PrintWriter(sw, true));
        return sw.toString();
    }

    /**
     * Turns on/off password mode. This causes command history to not get updated,
     * and causes input to appear as asterisks.
     * @param newPasswordMode true to turn on password mode, false to turn it off
     */
    public static void setPasswordMode(boolean newPasswordMode) {
        if (passwordMode == newPasswordMode) {
            return; // nothing to do
        }
        passwordMode = newPasswordMode;
        if (chatField.getSelectedText() != null || !newPasswordMode) {
            // in case a password was half-typed
            chatField.setText("");
        }
        chatField.setEchoChar(passwordMode ? '*' : 0);
    }

    /**
     * Turns on/off the login complete flag. Turning it off causes a reset
     * to the login state, basically. Turning it on, turns on features
     * like the numpad for movement.
     * @param loginComplete true if login is complete, false to cause a reset to login state
     */
    public static synchronized void setLoginComplete(boolean loginComplete) {
        GameClient.loginComplete = loginComplete;
        if (!loginComplete) {
            getMap().init(null, 0, 0, 0, 0, new HashMap<Point,PubRoom>());
            setRoomImage(null);
            roomEntityList.clear();
            groupList.clear();
        }
    }

    /**
     * Returns the login complete flag.
     * @return the login complete flag.
     */
    public static synchronized boolean getLoginComplete() {
        return loginComplete;
    }

    /**
     * Sends the specified command to the server.
     * @param command the command to send to the server
     */
    private static void sendCommand(String command) {
        send(new CommandClientEvent(command));
    }

    /**
     * Send a tracking update to the map.
     * @param player the id of the player to update
     * @param p the new point representing the player's location
     */
    public static void track(int player, Point p) {
        map.track(player, p);
    }

    /**
     * Launches the face chooser window.
     * @param face the initial face to display, or null for default/random.
     */
    public static void launchFaceChooser(Face face) {
        JFrame f = new FaceChooser(face);
        f.setVisible(true);
    }

    /**
     * Launches the console window.
     * @param type the script type
     * @param filename the initial filename
     * @param text the initial text
     */
    public static void launchScriptingConsole(ScriptType type, String filename, String text) {
        ConsoleWindow f = new ConsoleWindow(nextConsoleId++, type, filename, text);
        consoles.add(new WeakReference<ConsoleWindow>(f));
        f.setVisible(true);
    }

    /**
     * Launches the room editor window.
     * @param room the room to edit
     * @param description the room's description
     * @param uploadPrefix the filename prefix for new uploads
     * @param image the image associated with the room
     * @param isAdmin true if the player is an admin, false otherwise
     */
    public static void launchRoomEditor(final PubRoom room, String description, String uploadPrefix, String image, boolean isAdmin) {
        RoomEditor editor = new RoomEditor(room, description, uploadPrefix, image, isAdmin);
        editor.setVisible(true);
    }
    
    /**
     * Launches the text editor window.
     * @param type the type of text editor
     * @param id the id of the entity being edited
     * @param text the text to edit
     */
    public static void launchTextEditor(TextEditorType type, int id, String text) {
        TextEditor editor = new TextEditor(type, id, text);
        editor.setVisible(true);
    }

    /**
     * Sets the room image to the specified filename.
     * @param filename the new room image filename
     */
    public static void setRoomImage(String filename) {
        final Timer timer = new Timer(500, new ActionListener() {

            public void actionPerformed(ActionEvent e) {
                if (roomImageLoadCount > 0) {
                    loadingBar.setIndeterminate(true);
                    imageOverlayPanel.setVisible(true);
                }
            }
            
        });
        timer.setRepeats(false);
        timer.start();

        roomImageLoadCount++;
        ClientUtils.setImage(roomImageLabel, filename, new Runnable() {
            public void run() {
                if (--roomImageLoadCount == 0) {
                    imageOverlayPanel.setVisible(false);
                    loadingBar.setIndeterminate(false);
                }
                timer.stop();
            }
        });
    }

    /**
     * Sets the command line's color.
     * @param foreground the foreground color
     * @param background the background color
     */
    public static void setCommandLineColor(Color foreground, Color background) {
        chatField.setForeground(foreground);
        chatField.setBackground(background);
    }
    
    /**
     * Makes the client's menu bar.
     * @return the menu bar
     */
    private static JMenuBar makeMenuBar() {
        JMenuBar menubar = new JMenuBar();
        
        JMenu fileMenu = new JMenu("File");
        fileMenu.setMnemonic(KeyEvent.VK_F);
        JMenu settingsMenu = new JMenu("Settings");
        settingsMenu.setMnemonic(KeyEvent.VK_S);
        JMenu helpMenu = new JMenu("Help");
        helpMenu.setMnemonic(KeyEvent.VK_H);
        
        JMenuItem exitItem = new JMenuItem("Exit");
        exitItem.setMnemonic(KeyEvent.VK_X);
        fileMenu.add(exitItem);
        
        restoreItem = new JCheckBoxMenuItem("Automatically unminimize on game output", true);
        restoreItem.setMnemonic(KeyEvent.VK_U);
        settingsMenu.add(restoreItem);
        
        JMenuItem aboutItem = new JMenuItem("About...");
        aboutItem.setMnemonic(KeyEvent.VK_A);
        helpMenu.add(aboutItem);
        
        menubar.add(fileMenu);
        menubar.add(settingsMenu);
        menubar.add(helpMenu);
        
        exitItem.addActionListener(new ActionListener() {
            public void actionPerformed(ActionEvent e) {
                // Since we're using EXIT_ON_CLOSE anyway
                System.exit(0);
            }
        });
        
        restoreItem.addActionListener(new ActionListener() {
            public void actionPerformed(ActionEvent e) {
                if (restoreItem.isSelected()) {
                    println("Auto-unminimize is now ON.");
                }
                else {
                    println("Auto-unminimize is now OFF.");
                }
            }
        });
        
        aboutItem.addActionListener(new ActionListener() {
            public void actionPerformed(ActionEvent e) {
                showAboutBox();
            }
        });
        
        return menubar;
    }

    /**
     * Displays the about box.
     */
    private static void showAboutBox() {
        final JDialog aboutBox = new JDialog(mainFrame, "About", false);

        JPanel mainPanel = new JPanel();
        JLabel[] lines = new JLabel[] {
            new JLabel("Aelfengard"),
            new JLabel("Game Design and Crash-Free Code by Thedia"),
            new JLabel("Game Design and World-Class Artwork by Tho"),
            new JLabel("Game Design by Nisal"),
        };
        lines[0].setFont(lines[0].getFont().deriveFont(20.0f));
        mainPanel.setLayout(new GridLayout(lines.length, 1));
        for (JLabel line : lines) {
            line.setHorizontalAlignment(SwingConstants.CENTER);
            mainPanel.add(line);
        }
        final JButton okButton = new JButton("Ok");
        JPanel buttonPanel = new JPanel();
        buttonPanel.setLayout(new FlowLayout(FlowLayout.RIGHT, 5, 5));
        buttonPanel.add(okButton);
        
        aboutBox.add(mainPanel, BorderLayout.CENTER);
        aboutBox.add(buttonPanel, BorderLayout.SOUTH);
        okButton.addActionListener(new ActionListener() {
            public void actionPerformed(ActionEvent e) {
                aboutBox.dispose();
            }
        });
        
        Border border = new EmptyBorder(10, 10, 10, 10);
        border = new CompoundBorder(border, new BevelBorder(BevelBorder.LOWERED));
        border = new CompoundBorder(border, new EmptyBorder(10, 30, 10, 30));
        mainPanel.setBorder(border);
        
        aboutBox.pack();
        aboutBox.setLocationByPlatform(true);
        aboutBox.setVisible(true);
    }

    /**
     * Sends a stat update to the appropriate visual components.
     * @param tableType the table type to update
     * @param id the id of the entity being updated
     * @param statType the stat being modified
     * @param current the current value of the stat
     * @param max the max value of the stat
     */
    public static void statUpdate(TableType tableType, int id, StatType statType, int current, int max) {
        switch (tableType) {
            case ROOM_ENTITY_TABLE: {
                roomEntityList.statUpdate(id, statType, current, max);
                break;
            }
            case GROUP_MEMBER_TABLE: {
                groupList.statUpdate(id, statType, current, max);
                break;
            }
            default: throw new RuntimeException("Unrecognized table type: " + tableType);
        }
    }

    /**
     * The base URL for loading images.
     * @return the base URL
     */
    public static String getBaseURL() {
        return currentBaseURL;
    }
    
    /**
     * Configures the client with new information for reaching the server.
     * @param hostname the server's hostname
     * @param rcSubdir the directory containing game resources
     * @param gameMode the game mode string
     */
    public static void setCurrentServerInfo(String hostname, String rcSubdir, String gameMode) {
        currentBaseURL = null;
        try {
            currentBaseURL = System.getProperty("aelfengard.baseURL");
        }
        catch (Exception ex) {
            // didn't have access to read property
        }
        if (currentBaseURL == null) {
            currentBaseURL = "http://" + hostname + "/" + rcSubdir;
        }
        if (gameMode == null) {
            if (currentGameMode == null) {
                return; // nothing to do
            }
            gameModeLabel.setBorder(null);
            gameModeLabel.setText("");
            mainFrame.setTitle(DEFAULT_WINDOW_TITLE);
        }
        else {
            if (gameMode.equals(currentGameMode)) {
                return; // nothing to do
            }
            gameModeLabel.setBorder(new EmptyBorder(0, 5, 0, 5));
            if (extraModeInfo != null) {
                gameMode = extraModeInfo + " " + gameMode;
            }
            gameModeLabel.setText(gameMode);
            mainFrame.setTitle("AG - " + gameMode);
        }
        currentGameMode = gameMode;
    }

    /**
     * Extra mode info, such as "AUX" "MAIN" etc (used for -gui mode).
     * @param extraModeInfo the extra mode info string
     */
    public static void setExtraModeInfo(String extraModeInfo) {
        GameClient.extraModeInfo = extraModeInfo;
    }

    /**
     * Returns the group list.
     * @return the group list
     */
    public static EntityList<PubGroupMember> getGroupList() {
        return groupList;
    }

    /**
     * Returns the room list.
     * @return the room list
     */
    public static EntityList<PubRoomEntity> getRoomEntityList() {
        return roomEntityList;
    }
    
    /**
     * Prints the specified text to the client.
     * @param s the text to display
     */
    public static void println(String s) {
        displayPanel.println(s);
    }
    
    /**
     * Clears the display area.
     */
    public static void clear() {
        displayPanel.clear();
    }

    /**
     * Displays the specified face, along with optional text.
     * @param face the face to display
     * @param text the text to display, null for none
     */
    public static void sendFace(Face face, String... text) {
        displayPanel.sendFace(face, text);
    }
    
    /**
     * Sends the specified script text to the server for processing.
     * @param consoleId the console id
     * @param scriptType the script type
     * @param cmd the command to send
     */
    public static void executeManualScript(int consoleId, ScriptType scriptType, String cmd) {
        send(new ExecuteManualScriptClientEvent(consoleId, scriptType, cmd));
    }
    
    /**
     * Saves the specified script to the server.
     * @param consoleId the console id
     * @param type the script type
     * @param filename the script file name
     * @param script the script text
     */
    public static void saveScript(int consoleId, ScriptType type, String filename, String script) {
        send(new SaveScriptClientEvent(consoleId, type, filename, script));
    }

    /**
     * Displays the specified image, along with optional text.
     * @param filename the relative filename of the image to display
     * @param text the text to display, null for none
     */
    public static void sendImage(String filename, String... text) {
        displayPanel.sendImage(filename, text);
    }

    /**
     * Displays the specified table.
     * @param data the table data
     * @param headers the table headers
     * @param sizes the relative column widths
     */
    public static void printTable(String[][] data, String[] headers, int[] sizes) {
        displayPanel.printTable(data, headers, sizes);
    }
    
    /**
     * Negotiates an encryption key using DH key agreement.
     * @param in the input stream
     * @param out the output stream
     * @throws Exception if a problem occurs
     */
    private static void negotiateEncryption(DataInput in, DataOutput out) throws Exception {
        displayPanel.print("Negotiating encryption key...");
        // DH stands for Diffie-Hellman
        KeyPairGenerator kpg = KeyPairGenerator.getInstance("DH");
        kpg.initialize(1024);
        KeyPair kp = kpg.generateKeyPair();
        
        // Send our public key
        byte[] keyMaterial = kp.getPublic().getEncoded();
        out.writeInt(keyMaterial.length);
        out.write(keyMaterial);
        
        // wait for public key from server
        keyMaterial = new byte[in.readInt()];
        in.readFully(keyMaterial);
        
        KeyFactory kf = KeyFactory.getInstance("DH");
        PublicKey serverKey = kf.generatePublic(new X509EncodedKeySpec(keyMaterial));
        
        // Generate secret key
        KeyAgreement dh = KeyAgreement.getInstance("DH");
        dh.init(kp.getPrivate());
        dh.doPhase(serverKey, true);
        SecretKey sharedSecret = dh.generateSecret("DESede"); // yay

        encipher = Cipher.getInstance("DESede/CBC/PKCS5Padding");
        encipher.init(Cipher.ENCRYPT_MODE, sharedSecret);
        
        // send our IV
        byte[] iv = encipher.getIV();
        out.writeInt(iv.length);
        out.write(iv);

        // read server's IV
        iv = new byte[in.readInt()];
        in.readFully(iv);
        
        decipher = Cipher.getInstance("DESede/CBC/PKCS5Padding");
        decipher.init(Cipher.DECRYPT_MODE, sharedSecret, new IvParameterSpec(iv));

        println(" @2ZZDone!");
    }
    
    /**
     * Used for the command history.
     */
    private static class HistoryListModel extends AbstractListModel {

        private static final long serialVersionUID = 3976735852623638577L;

        private static String[] history = new String[100];

        private static int historyIdx = 0;

        private static int historyPtr = 0;

        private static int historyMax = 0;
        
        public HistoryListModel() {
            history[0] = "";
        }

        public int getSize() {
            return historyMax + 1;
        }

        public Object getElementAt(int index) {
            return history[(historyIdx - historyMax + index + history.length) % history.length];
        }
        
        public void add(String cmd) {
            // if (!cmd.equals(history[(history.length + historyIdx - 1) % history.length])) {
            // (not same as last command, so add to history)
            if (true) {
                history[historyIdx++] = cmd;
                historyIdx %= history.length;
                history[historyIdx] = "";
                if (historyMax < history.length - 1) {
                    historyMax++;
                    fireIntervalAdded(this, historyMax, historyMax);
                }
                else {
                    fireIntervalAdded(this, historyMax, historyMax);
                    fireIntervalRemoved(this, 0, 0);
                }
                commandList.ensureIndexIsVisible(historyMax - 1);
                commandList.setSelectedIndex(historyMax - 1);
            }
            historyPtr = historyMax == 0 ? 0 : 1;
        }
        
        public String handleScroll(boolean isUp, String currentCmd) {
            if (!String.valueOf(chatField.getPassword()).equals(
                    history[(history.length + historyIdx - historyPtr)
                            % history.length])) {
                historyPtr = 0;
            }
            if (isUp) {
                if (historyPtr < historyMax) {
                    historyPtr++;
                }
            }
            else {
                if (historyPtr > 0) {
                    historyPtr--;
                }
            }
            return history[(history.length + historyIdx - historyPtr)
                    % history.length];
        }

    }

    public static void printTextToScriptingConsole(int consoleId, String text) {
        ConsoleWindow window = getConsoleWindow(consoleId);
        if (window != null) {
            window.write(text);
        }
    }

    public static void consoleSaveResponse(int consoleId, boolean error, String msg) {
        ConsoleWindow window = getConsoleWindow(consoleId);
        if (window != null) {
            window.saveResponse(error, msg);
        }
    }
    
    public static ImageIcon getImageIcon(String path) {
        try {
            String baseURL = getBaseURL().replaceAll(":|/", "_");
            File finalF = new File(".aelfengard-cache/" + baseURL + "/" + path);
            if (!finalF.exists()) {
                synchronized(imageCacheLock) {
                    File f = new File(".aelfengard-cache/" + baseURL + "/" + path + ".new");
                    byte[] buf = new byte[16384];
                    URL url = new URL(getBaseURL() + "/" + path);
                    f.getParentFile().mkdirs();
                    FileOutputStream out = new FileOutputStream(f);
                    URLConnection conn = url.openConnection();
                    InputStream in = conn.getInputStream();
                    while (true) {
                        int count = in.read(buf);
                        if (count < 0) {
                            break; // EOF
                        }
                        out.write(buf, 0, count);
                    }
                    out.flush();
                    out.close();
                    f.renameTo(finalF);
                }
            }
            return new ImageIcon(finalF.getAbsolutePath());
        }
        catch (Exception ex) {
            try {
                return new ImageIcon(new URL(getBaseURL() + "/" + path));
            }
            catch (MalformedURLException muex) {
                muex.printStackTrace();
                return new ImageIcon();
            }
        }
    }
    
    private static ConsoleWindow getConsoleWindow(int consoleId) {
        Iterator<WeakReference<ConsoleWindow>> iter = consoles.iterator();
        while (iter.hasNext()) {
            WeakReference<ConsoleWindow> ref = iter.next();
            ConsoleWindow window = ref.get();
            if (window == null) {
                iter.remove();
            }
            else if (window.getConsoleId() == consoleId) {
                return window;
            }
        }
        return null;
    }

    public static void doImageUpload(int npcId) {
        try {
            InputStream in;
            String filename;
            try {
                FileOpenService service = (FileOpenService) 
                        ServiceManager.lookup("javax.jnlp.FileOpenService");
                FileContents contents = 
                        service.openFileDialog(null, new String[] {"gif", "jpg", "png"});
                if (contents == null) {
                    return;
                }
                in = contents.getInputStream();
                filename = contents.getName();
            } catch (UnavailableServiceException ex) {
                // try using regular file open dialog
                JFileChooser chooser = new JFileChooser();
                chooser.setFileFilter(new FileFilter() {
                
                    @Override
                    public String getDescription() {
                        return "Image Files";
                    }
                
                    @Override
                    public boolean accept(File f) {
                        return f.isDirectory() || 
                               f.getName().matches(".*\\.jpg$|.*\\.gif$|.*\\.png$");
                    }
                
                });
                if (chooser.showOpenDialog(mainFrame) != JFileChooser.APPROVE_OPTION) {
                    return;
                }
                File f = chooser.getSelectedFile();
                if (f.length() > 256 * 1024) {
                    JOptionPane.showMessageDialog(mainFrame, "Sorry, that file is too large.", "Max Size Exceeded", JOptionPane.ERROR_MESSAGE);
                    return;
                }
                in = new FileInputStream(f);
                filename = f.getName();
            }
            // Either way, we should now have an InputStream
            ByteArrayOutputStream baos = new ByteArrayOutputStream();
            byte[] buf = new byte[16384];
            while (true) {
                int count = in.read(buf);
                if (count < 0) {
                    break; // EOF
                }
                baos.write(buf, 0, count);
            }
            byte[] uploadData = baos.toByteArray();
            send(new ImageUploadClientEvent(npcId, filename, uploadData));
        }
        catch (IOException ex) {
            ex.printStackTrace();
            JOptionPane.showMessageDialog(mainFrame, ex.toString(), "I/O Error", JOptionPane.ERROR_MESSAGE);
        }
    }

}