package com.knutejohnson.pi.chat;

import java.awt.*;
import java.awt.event.*;
import java.io.*;
import java.net.*;
import java.nio.file.*;
import java.util.*;
import java.util.concurrent.*;
import static java.util.stream.Collectors.*;
import javax.swing.*;

/**
 * <div>
 * ChatServer is the center point of the chat system.  All client messages pass
 * through the ChatServer and are distributed to the clients.  The ChatServer
 * managers the users and their credentials, and displays all the message
 * traffic passing throgh the server.  The ChatServer allows the sending of
 * messages to a user, a channel or to all logged in users.
 * </div>
 * <div style="margin-top: 30px;">
 * TODO: javadocs
 * </div>
 * <table style="margin-top: 30px; width: 100%; border-collapse: collapse;" border cellspacing="2" summary="Version info">
 * <tr><th>Version<th>Date<th>Modification
 * <tr><td>0.10.0<td style="white-space: nowrap;">28 May 2017<td>incept
 * <tr><td>0.11.0<td>06 Jun 2017<td>change loadUsers stream from collect(toConcurrentMap( to collect(toMap( 
 * <tr><td>0.20.0<td>06 Jun 2017<td>add command line option and associated code to use a browser based user management system
 * <tr><td>0.20.1<td>07 Jun 2017<td>update directions with information on the browser based user management system
 * </table>
 * 
 * @author  Knute Johnson
 * @version 0.20.1 - 7 June 2017
 */
public class ChatServer extends JFrame implements Runnable {
    public static final String VERSION = "0.20.1";
    public static final String DATE = "7 June 2017";
    private static final int SERVER_PORT = 28888;
    private static final int CLIENT_PORT = 28889;
    private static final File USER_HOME =
     new File(System.getProperty("user.home"));
    private static final File DIRECTORY = new File(USER_HOME,".chatserver");
    private static final File PASSWORD_FILE = new File(DIRECTORY,".passwords");
    private static final File PROPERTIES_FILE =
     new File(DIRECTORY,".properties");
    private static final String DIRECTIONS_FILE_NAME = "server.html";
    private static final Color RASPBERRY = new Color(227,11,92);
    private static boolean WEB;
    private final Properties properties = new Properties();
    private final ConcurrentMap<String,User> users = new ConcurrentHashMap<>();
    private volatile DatagramSocket socket;
    private volatile boolean runFlag;
    private final Thread thread = new Thread(this);
    private final ReceiverArea area;
    private final JTextField field;

    public ChatServer() {
        super("ChatServer - " + VERSION + " - " + DATE);

        // if the user.home/.chatserver directory doesn't exist, create it
        if (!DIRECTORY.exists())
            if (!DIRECTORY.mkdir())
                JOptionPane.showMessageDialog(this,
                 "Unable To Create ~/.chatserver Directory");

        try {
            loadProperties(properties);
        } catch (IOException ioe) {
            ioe.printStackTrace();
            JOptionPane.showMessageDialog(this,
             "Load Properties Failed!\n" + ioe);
        }

        // load the users
        try {
            loadUsers(users);
        } catch (IOException ioe) {
            ioe.printStackTrace();
            JOptionPane.showMessageDialog(this,
             "Unable To Load Users Database\n" + ioe);
        }

        // if in WEB mode, load the users from the file every 30 seconds
        if (WEB) {
            Runnable updateUsers = () -> {
                while (true) {
                    try {
                        loadUsersWeb(users);
                        Thread.sleep(30000);
                    } catch (IOException|InterruptedException ex) {
                        ex.printStackTrace();
                    }
                }
            };
            Thread updateUsersThread = new Thread(updateUsers,"UpdateUsers");
            updateUsersThread.setDaemon(true);
            updateUsersThread.start();
        }

        addWindowListener(new WindowAdapter() {
            // when the window is opened, start the main thread
            public void windowOpened(WindowEvent we) {
                start();
            }
            // when the window closes, stop the main thread and dispose the
            //  window
            public void windowClosing(WindowEvent we) {
                stop();
                dispose();
            }
        });

        JMenuBar menuBar = new JMenuBar();
        setJMenuBar(menuBar);
        JMenu file = menuBar.add(new JMenu("File"));
        JMenu edit = menuBar.add(new JMenu("Edit"));
        JMenu view = menuBar.add(new JMenu("View"));
        JMenu help = menuBar.add(new JMenu("Help"));

        JMenuItem mi;
        mi = file.add("Quit");
        mi.addActionListener(ae -> { stop();  dispose(); });

        if (!WEB) {
            mi = edit.add("Add or Change User");
            mi.addActionListener(ae -> {
                JPanel panel = new JPanel(new GridBagLayout());
                GridBagConstraints c = new GridBagConstraints();
                c.insets = new Insets(2,2,2,2);
                c.anchor = GridBagConstraints.WEST;
                c.gridy = 0;
                panel.add(new JLabel("Handle:"),c);
                JTextField handleField = new JTextField(12);
                panel.add(handleField,c);
                ++c.gridy;
                panel.add(new JLabel("Password:"),c);
                JPasswordField passwordField = new JPasswordField(12);
                panel.add(passwordField,c);

                int option = JOptionPane.showOptionDialog(this,panel,
                 "Add or Change User",JOptionPane.OK_CANCEL_OPTION,
                 JOptionPane.QUESTION_MESSAGE,null,null,null);

                if (option == JOptionPane.OK_OPTION) {
                    String handle = handleField.getText();
                    String passwd = new String(passwordField.getPassword());
                    // if the handle is a channel, login or logout
                    if (handle.matches("(?i)chan[x1-5]") ||
                     handle.equalsIgnoreCase("login") ||
                     handle.equalsIgnoreCase("logout")) {
                        JOptionPane.showMessageDialog(this,
                         "Handle Is Invalid!");
                    // if the handle or password is empty
                    } else if (handle.equals("") || passwd.equals("")) {
                        JOptionPane.showMessageDialog(this,
                         "Handle or Password are Empty!");
                    // if the handle has too few or too many characters
                    } else if (handle.length() < 2 || handle.length() > 16) {
                        JOptionPane.showMessageDialog(this,
                         "Handle Must Have Between 2 and 16 Characters!");
                    // otherwise
                    } else {
                        String msg;
                        // if the handle is NOT in the database
                        if (users.get(handle) == null) {
                            // create new handle and put it in database
                            users.put(handle,new User(handle,passwd,null));
                            msg = String.format("User %s Added.",handle);
                        } else {
                            // update the user's password
                            users.get(handle).setPassword(passwd);
                            msg = String.format("User %s's Password Changed.",
                             handle);
                        }
                        // store the users database
                        try {
                            storeUsers(users);
                            JOptionPane.showMessageDialog(this,msg);
                        } catch (IOException ioe) {
                            ioe.printStackTrace();
                            JOptionPane.showMessageDialog(this,
                             "Error Storing Users\n" + ioe);
                        }
                    }
                }
            });
        }

        if (!WEB) {
            mi = edit.add("Delete User");
            mi.addActionListener(ae -> {
                String handle = JOptionPane.showInputDialog(this,"Handle:","");
                // if the dialog wasn't dismissed and a handle was entered 
                if (handle != null && !handle.equals("")) {
                    // if the user is not in the database
                    if (users.get(handle) == null) {
                        JOptionPane.showMessageDialog(this,
                         handle + " Is Not In The User Database!");
                    } else {
                        users.remove(handle);
                        try {
                            storeUsers(users);
                            JOptionPane.showMessageDialog(this,
                             handle + " Has Been Removed From The Database.");
                        } catch (IOException ioe) {
                            ioe.printStackTrace();
                            JOptionPane.showMessageDialog(this,
                             "Error Saving Users Database!\n" + ioe);
                        }
                    }
                }
            });
        }

        mi = edit.add("Bind Address");
        mi.addActionListener(ae -> {
            String newAddress = JOptionPane.showInputDialog(this,
             "Bind Address:",properties.getProperty("address","0.0.0.0"));
            if (newAddress != null) {
                try {
                   InetAddress address = InetAddress.getByName(newAddress);
                   properties.setProperty("address",address.getHostAddress());
                   storeProperties(properties);
                   // restart the main thread with the new IP
                   socket.close();
                } catch (IOException ioe) {
                    ioe.printStackTrace();
                    if (ioe instanceof UnknownHostException)
                        JOptionPane.showMessageDialog(this,
                         "Not a Valid IP Address!\n" + ioe);
                    else
                        JOptionPane.showMessageDialog(this,
                         "Store Properties Failed!\n" + ioe);
                }
            }
        });

        mi = view.add("Users");
        mi.addActionListener(ae -> {
            String hdr =
             "<html>\n" +
             "<head>\n" +
             "<style type=\"text/css\">" +
             "body { margin: 5px; }\n" +
             "</style>\n" +
             "</head>";
            String usrs =
             users.entrySet().stream().
             map(e -> e.getValue().toString()).
             sorted().
             collect(joining("<br>\n"));
            String tail =
             "</body>\n" +
             "</html>\n";
            String html = hdr.concat(usrs == null ? "" : usrs).concat(tail);
            JScrollPane pane = new JScrollPane(new JLabel(html),
             JScrollPane.VERTICAL_SCROLLBAR_AS_NEEDED,
             JScrollPane.HORIZONTAL_SCROLLBAR_NEVER);
            pane.setPreferredSize(new Dimension(350,180));
            JOptionPane.showMessageDialog(this,pane);
        });

        // read directions file from the program jar and create a menu item for
        //  it if it is found
        try (InputStream is =
         getClass().getResourceAsStream(DIRECTIONS_FILE_NAME)) {
            if (is != null) {
                try (BufferedReader reader = new BufferedReader(
                 new InputStreamReader(is))) {
                    String html = reader.lines().collect(joining("\n"));
                    mi = help.add("Directions");
                    help.addSeparator();
                    mi.addActionListener(ae -> {
                        // create a JLabel since it displays html
                        JLabel label = new JLabel(html);
                        // create a scroll pane to hold the label
                        JScrollPane pane = new JScrollPane(label,
                         JScrollPane.VERTICAL_SCROLLBAR_ALWAYS,
                         JScrollPane.HORIZONTAL_SCROLLBAR_NEVER);
                        // get the scroll pane's vertical scrollbar
                        JScrollBar vBar = pane.getVerticalScrollBar();
                        // get the dimensions of the label
                        Dimension labelDim = label.getPreferredSize();
                        // get the scroll bar's dimensions
                        Dimension barDim = vBar.getPreferredSize();
                        // set the preferred size of the scroll bar to be the
                        //  width of the label and scroll bar by 200 pixels
                        pane.setPreferredSize(new Dimension(
                         labelDim.width + barDim.width,200));
                        JOptionPane.showMessageDialog(this,pane);
                    });
                }
            }
        } catch (IOException ioe) {
            ioe.printStackTrace();
            JOptionPane.showMessageDialog(this,
             "Error Reading Directions!\n" + ioe);
        }

        mi = help.add("About");
        mi.addActionListener(ae -> JOptionPane.showMessageDialog(this,
         "ChatServer\nVersion: " + VERSION + "\nDate: " + DATE +
         "\nWritten by: Knute Johnson"));

        area = new ReceiverArea(new SelfLimitingDocument(2097152),"",24,120);
        area.setEditable(false);
        area.setLineWrap(true);
        area.setWrapStyleWord(true);
        area.setFont(new Font("Monospaced",Font.PLAIN,12));
        area.setForeground(RASPBERRY);
        // force the focus to go to the text input field
        area.addFocusListener(new FocusAdapter() {
            public void focusGained(FocusEvent fe) {
                field.requestFocus();
            }
        });
        JScrollPane pane = new JScrollPane(area);
        add(pane,BorderLayout.CENTER);

        field = new JTextField(60);
        // pressing F1 through F5 types the chan[1-5] address into the text
        // field
        field.addKeyListener(new KeyAdapter() {
            public void keyPressed(KeyEvent ke) {
                String chan;
                switch (ke.getKeyCode()) {
                    case KeyEvent.VK_F1:  chan = "chan1 ";  break;
                    case KeyEvent.VK_F2:  chan = "chan2 ";  break;
                    case KeyEvent.VK_F3:  chan = "chan3 ";  break;
                    case KeyEvent.VK_F4:  chan = "chan4 ";  break;
                    case KeyEvent.VK_F5:  chan = "chan5 ";  break;
                    default:  chan = "";  break;
                }
                if (!chan.equals(""))
                    field.setText(chan.concat(field.getText()));
            }
        });
        field.addActionListener(ae -> {
            // strip the whitespace from the message string
            String str = field.getText().trim();
            // split the string on a space
            String[] arr = str.split(" ");
            // dest is the first array element if there is more than one
            //  otherwise it is the string itself
            String dest = arr.length > 1 ? arr[0] : str;
            // the text is everything after the first space
            String text = str.substring(dest.length()).trim();

            // create a message from the data above, if the dest is in the user
            //  database add its address to the message
            Message msg = new Message("SERVER","",dest,text,
             users.get(dest) == null ? null : users.get(dest).getAddress());

            // if (the user is in the database and his address is not null) OR
            //  the dest matches chan[x1-5], send the message
            if ((users.get(dest) != null &&
             users.get(dest).getAddress() != null) ||
             dest.matches("(?i)chan[x1-5]")) {
                try {
                    sendMessage(msg);
                    field.setText("");
                    area.append("+" + msg.toServerString());
                } catch (IOException ioe) {
                    ioe.printStackTrace();
                    JOptionPane.showMessageDialog(this,
                     "Error Sending Message!\n" + ioe);
                }
            // if the dest user is not in the database show error message
            } else if (users.get(dest) == null) {
                msg.setText("USER IS NOT IN THE DATABASE!");
                area.append("!" + msg.toServerString());
            // if the dest user is not logged in show error message
            } else if (users.get(dest).getAddress() == null) {
                msg.setText("USER IS NOT LOGGED IN!");
                area.append("!" + msg.toServerString());
            }
        });
        field.setForeground(RASPBERRY);
        add(field,BorderLayout.SOUTH);

        pack();
        setVisible(true);
    }

    public void start() {
        // if the thread has not yet been started
        if (thread.getState() == Thread.State.NEW) {
            runFlag = true;
            thread.start();
        }
    }

    public void run() {
        while (runFlag) {
            try {
                socket = new DatagramSocket(new InetSocketAddress(
                 properties.getProperty("address","0.0.0.0"),SERVER_PORT));

                while (true) {
                    // create a receiver datagram packet
                    DatagramPacket packet =
                     new DatagramPacket(new byte[1500],1500);
                    // block until packet received
                    socket.receive(packet);

                    // create a Message from the received datagram packet
                    //  displaying an error message if the packet format was
                    //  invalid
                    Message clientMsg;
                    try {
                        clientMsg = new Message(packet);
                        clientMsg.setAddress(packet.getAddress());
                    } catch (IllegalArgumentException iae) {
                        iae.printStackTrace();
                        area.append(String.format("BAD PACKET: %s\n",
                         new String(packet.getData(),packet.getOffset(),
                         packet.getLength())));
                        continue;
                    }

                    // display the inbound message
                    area.append(">" + clientMsg.toServerString());

                    // sender's handle not in users database
                    if (users.get(clientMsg.getHandle()) == null) {
                        clientMsg.setText("SENDER IS NOT A REGISTERED USER");
                        area.append("!" + clientMsg.toServerString());
                        continue;
                    }

                    // update the sender's address in the user's entry in the
                    //  database
                    users.put(clientMsg.getHandle(),
                     users.get(clientMsg.getHandle()).
                     setAddress(clientMsg.getAddress()));

                    // sender's password doesn't match users database
                    if (!clientMsg.getPassword().equals(
                     users.get(clientMsg.getHandle()).getPassword())) {
                        Message badPasswd = new Message("SERVER","",
                         clientMsg.getHandle(),
                         "YOUR HANDLE AND PASSWORD DO NOT MATCH!",
                         clientMsg.getAddress());
                        sendMessage(badPasswd);
                        area.append("<" + badPasswd.toServerString());
                        continue;
                    }

                    // if this is a login message send back acknowledgement
                    if (clientMsg.getDest().equalsIgnoreCase("login")) {
                        Message login = new Message("SERVER","",
                         clientMsg.getHandle(),"You are logged in!",
                         clientMsg.getAddress());
                        sendMessage(login);
                        area.append("<" + login.toServerString());
                        continue;
                    }

                    // if this is a logout message send back acknowledgment
                    if (clientMsg.getDest().equalsIgnoreCase("logout")) {
                        users.get(clientMsg.getHandle()).setAddress(null);
                        Message logout = new Message("SERVER","",
                         clientMsg.getHandle(),"You are logged out!",
                         clientMsg.getAddress());
                        sendMessage(logout);
                        area.append("<" + logout.toServerString());
                        continue;
                    }

                    // if this is a message to SERVER display it
                    if (clientMsg.getDest().equalsIgnoreCase("server")) {
                        area.append("*" + clientMsg.toServerString());
                        continue;
                    }

                    // if the message is NOT (addressed to a current user OR
                    //  one of the valid channels) send back an error message
                    if (!users.containsKey(clientMsg.getDest()) &&
                     !clientMsg.getDest().toLowerCase().
                     matches("(?i)chan[1-5]")) {
                        Message noUser = new Message("SERVER","",
                         clientMsg.getHandle(),String.format(
                         "USER %s NOT IN DATABASE!",clientMsg.getDest()),
                         users.get(clientMsg.getHandle()).getAddress());
                        sendMessage(noUser);
                        area.append("<" + noUser.toServerString());
                        continue;
                    }

                    // if the message
                    //  (is NOT addressed to one of the channels AND
                    //  the user is NOT online) send back an error message
                    if (!clientMsg.getDest().toLowerCase().matches(
                     "(?i)chan[1-5]") &&
                     users.get(clientMsg.getDest()).getAddress() == null) {
                        Message notOnline = new Message("SERVER","",
                         clientMsg.getHandle(),String.format(
                         "USER %s NOT ONLINE!",clientMsg.getDest()),
                         users.get(clientMsg.getHandle()).getAddress());
                        sendMessage(notOnline);
                        area.append("<" + notOnline.toServerString());
                        continue;
                    }

                    // if message is NOT addressed to a channel, put the user's
                    //  address in the message
                    if (!clientMsg.getDest().toLowerCase().
                     matches("(?i)chan[1-5]"))
                        clientMsg.setAddress(
                         users.get(clientMsg.getDest()).getAddress());

                    // send message addressed to an online user or a channel
                    //  and display it
                    sendMessage(clientMsg);
                    area.append("<" + clientMsg.toServerString());
                }
            } catch (IOException ioe) {
                if (runFlag)
                    System.out.println(ioe);
            }
        }
    }

    public void stop() {
        runFlag = false;
        socket.close();
    }

    public void loadUsers(Map<String,User> map) throws IOException {
        // read the password file, create a new User and store in the map
        map.putAll(Files.lines(PASSWORD_FILE.toPath()).
         map(s -> s.split("=")).
         map(a -> new User(a[0],a[1],null)).
         collect(toMap(User::getHandle,u -> u)));
    }

    public void loadUsersWeb(Map<String,User> map) throws IOException {
        // get a FileInputStream from the password file
        try (FileInputStream fis = new FileInputStream(PASSWORD_FILE)) {
            // get a FileChannel from the FileInputStream and lock the channel
            // the channel unlocks automatically when the file is closed
            fis.getChannel().lock(0,Long.MAX_VALUE,true);
            // get a BufferedReader from the FileInputStream
            BufferedReader br = new BufferedReader(new InputStreamReader(fis));
            // read the handle/password pairs from the password file and store
            //  in a map
            Map<String,User> fileMap = br.lines().
             map(s -> s.split("=")).
             map(a -> new User(a[0],a[1],null)).
             collect(toMap(User::getHandle,u -> u));
            // put all the users from the file into the users map
            map.putAll(fileMap);
            // get a set of keys from the user's map
            Set<String> uSet = new HashSet<String>(map.keySet());
            // get a set of keys from the file map
            Set<String> fSet = fileMap.keySet();
            // remove the file map keys from the user map key set, what's left
            //  are the users that are no longer in the file (deleted users)
            uSet.removeAll(fSet);
            // remove the deleted users from the user's map
            for (String key : uSet)
                map.remove(key);
        }
    }

    public void storeUsers(Map<String,User> map) throws IOException {
        // create a list of user handles and passwords separated by =
        java.util.List<String> list = map.entrySet().stream().
         map(e -> e.getKey().concat("=").concat(e.getValue().getPassword())).
         collect(toList());

        // write the list of handles and passwords to the users file
        Files.write(PASSWORD_FILE.toPath(),list,StandardOpenOption.CREATE,
         StandardOpenOption.WRITE,StandardOpenOption.TRUNCATE_EXISTING);
    }

    private void sendMessage(Message msg) throws IOException {
        // get a datagram packet from the message
        DatagramPacket packet = msg.toPacket();
        // we only send out bound to users so always the client port
        packet.setPort(CLIENT_PORT);

        // if the message is addressed to chan[1-5]
        if (msg.getDest().matches("(?i)chan[1-5]")) {
            for (User user : users.values()) {
                // send every logged in user the message with the channel as
                //  the dest
                if (user.getAddress() != null) {
                    packet.setAddress(user.getAddress());
                    socket.send(packet);
                }
            }
        // the message is addressed to chanx
        } else if (msg.getDest().equalsIgnoreCase("chanx")) {
            // send every logged in user the message but address it to them
            for (Map.Entry<String,User> entry : users.entrySet()) {
                if (entry.getValue().getAddress() != null) {
                    Message xMsg = new Message("SERVER",
                     entry.getValue().getPassword(),entry.getKey(),
                     msg.getText(),entry.getValue().getAddress());
                    DatagramPacket xPacket = xMsg.toPacket();
                    xPacket.setPort(CLIENT_PORT);
                    socket.send(xPacket);
                }
            }
        // if the message is addressed to a user, send them the message
        } else {
            packet.setAddress(msg.getAddress());
            socket.send(packet);
        }
    }

    private void loadProperties(Properties props) throws IOException {
        try (FileReader reader = new FileReader(PROPERTIES_FILE)) {
            props.load(reader);
        }
    }

    private void storeProperties(Properties props) throws IOException {
        try (FileWriter writer = new FileWriter(PROPERTIES_FILE)) {
            props.store(writer,"ChatServer");
        }
    }

    public static void main(String... args) {
        // look for the 'web' command line option
        for (String arg : args)
            if (arg.equalsIgnoreCase("web"))
                WEB = true;

        EventQueue.invokeLater(() -> new ChatServer());
    }
}