// ============================================================================
// File:               $File$
//
// Project:            
//
// Purpose:            
//
// Author:             Rammi
//                     This code is in the public domain.
//                     Use at own risk.
//                     No guarantees given.
//
// Copyright Notice:   (c) 2008  Rammi (rammi@caff.de)
//
// Latest change:      $Date$
//
// History:	       $Log$
//=============================================================================
package de.caff.asteroid;

import de.caff.asteroid.server.DatagramListener;
import de.caff.asteroid.server.DatagramSender;
import de.caff.util.Tools;

import java.io.IOException;
import java.net.*;
import java.util.*;

/**
 * UDP communication with MAME.
 *
 * This class is part of a solution for a
 * <a href="http://www.heise.de/ct/creativ/08/02/details/">competition by the German computer magazine c't</a>.
 */
public class Communication
        implements Runnable,
                   GameData,
                   DatagramSender,
                   PingKeyProvider
{
  /** The state which we currently have (useful if playing online). */
  public static enum ServerState
  {
    UNKNOWN,        // not yet known (server hasn't answered)
    SERVER_BUSY,    // server is busy with other client (only online)
    IN_GAME,        // server plays with us,
    GAME_OVER       // game over (only online)
  }

  /** Listener for server state changes. */
  public static interface ServerStateListener
  {
    /**
     *  Called on server state changes.
     *  @param oldState old server state
     *  @param newState new server state
     */
    void serverStateChanged(ServerState oldState, ServerState newState);

    /**
     *  Called if new information on frames to wait during {@link de.caff.asteroid.Communication.ServerState#SERVER_BUSY} state is received.
     *  @param framesToWait new frames to wait number
     */
    void framesToWaitUpdate(int framesToWait);
  }

  private static class SendInfo
  {
    private final long timestamp;
    private final byte keys;
    private final byte ping;

    public SendInfo(byte keys, byte ping)
    {
      this.timestamp = System.currentTimeMillis();
      this.keys = keys;
      this.ping = ping;
    }

    public long getTimestamp()
    {
      return timestamp;
    }

    public int getKeys()
    {
      return Tools.byteToUnsigned(keys);
    }

    public int getPing()
    {
      return Tools.byteToUnsigned(ping);
    }
  }

  /** Maximum capacity of the pendingFrames fifo. */
  public static final int MAX_FRAMES_KEPT = 256;

  /** Socket info. */
  private DatagramSocket socket;
  /** FIFO of frames, sorted in the order they are received. The maximum capacity is kept to {@link #MAX_FRAMES_KEPT}. */
  private LinkedList<FrameInfo> pendingFrames = new LinkedList<FrameInfo>();
  /** Reused byte array for sending the keys. */
  private byte[] keyFrame = new byte[KEY_PACKET_SIZE];
  {
    System.arraycopy(KEY_PACKET_INTRO, 0, keyFrame, 0, KEY_PACKET_INTRO.length);
  }
  /** Reused datagram for sending the keys. */
  private DatagramPacket keyDatagram = new DatagramPacket(keyFrame, keyFrame.length);
  /** Reused datagram for frame data receive. */
  private DatagramPacket mameDatagram = new DatagramPacket(new byte[FrameInfo.MAME_DATAGRAM_SIZE], FrameInfo.MAME_DATAGRAM_SIZE);
  /** Registered frame listeners. */
  private List<FrameListener> frameListeners = new LinkedList<FrameListener>();
  /** Registered datagram listeners. */
  private List<DatagramListener> datagramListeners = new LinkedList<DatagramListener>();
  /** Registered server state listeners. */
  private List<ServerStateListener> serverStateListeners = new LinkedList<ServerStateListener>();
  /** The ping of the next key datagram. */
  private int ping = 1;
  /** The ping of the latest key datagram. */
  private int latestPing = 0;
  /** Infos about key datagrams, sorted by the associated ping. */
  private SendInfo[] sendInfos = new SendInfo[256];
  /** Address of MAME. */
  private InetSocketAddress mameAddr;
  /** Frame preparer. */
  private FramePreparer framePreparer;
  /** The buttons. */
  private Buttons buttons = new Buttons();
  /** Brigde mode? */
  private boolean bridgeMode;
  /** The server state. */
  private ServerState serverState = ServerState.UNKNOWN;
  /** The name datagram data to sent to the server (indicates online game if not null). */
  private DatagramPacket nameDatagram;

  /**
   * Constructor.
   * @param hostname hostname to use
   * @param bridgeMode run in bridge mode (then all key-related methods are useless,
   *                   because no keys datagrams are send to MAME)
   * @throws SocketException on connection problems
   */
  public Communication(String hostname, boolean bridgeMode) throws IOException
  {
    this(hostname, bridgeMode, null);
  }

  /**
   * Constructor for usage in online game.
   * Do <b>not</b> use in local games because this will crash the modified MAME!
   * @param hostname hostname to use
   * @param bridgeMode run in bridge mode (then all key-related methods are useless,
   *                   because no keys datagrams are send to MAME)
   * @param playerName the player name to send in online game (indicates online game if not <code>null</code>)
   * @throws SocketException on connection problems
   */
  public Communication(String hostname, boolean bridgeMode, String playerName) throws IOException
  {
    this.bridgeMode = bridgeMode;
    int port = MAME_PORT;
    String[] parts = hostname.split(":");
    if (parts.length == 2) {
      try {
        port = Integer.parseInt(parts[1]);
        hostname = parts[0];
      } catch (NumberFormatException e) {
        e.printStackTrace();
      }
    }
    socket = new DatagramSocket();
    mameAddr = new InetSocketAddress(hostname, port);
    socket.connect(mameAddr);
    if (playerName != null) {
      byte[] nameData = new byte[NAME_PACKET_SIZE];
      System.arraycopy(NAME_PACKET_INTRO, 0, nameData, 0, NAME_PACKET_INTRO.length);
      byte[] nameBytes = playerName.getBytes("utf-8");
      System.arraycopy(nameBytes, 0, nameData, NAME_PACKET_INTRO.length,
                       Math.min(nameBytes.length, nameData.length - NAME_PACKET_INTRO.length));
      nameDatagram = new DatagramPacket(nameData, nameData.length);
    }
  }

  /**
   * Set a new server state.
   * @param state new server state
   * @throws IOException on i/o errors
   */
  private void setServerState(ServerState state) throws IOException
  {
    if (state != serverState) {
      if (state == ServerState.IN_GAME  &&
          serverState == ServerState.UNKNOWN) {
        if (nameDatagram != null) {
          // send player name
          socket.send(nameDatagram);
        }
      }
      ServerState oldState;
      synchronized (this) {
        oldState = serverState;
        serverState = state;
      }
      informServerStateListeners(oldState, state);
    }
  }

  /**
   *  Restart the state machine.
   *  This is only useful in online mode where it allows to play another game.
   */
  public synchronized void restart()
  {
    try {
      setServerState(ServerState.UNKNOWN);
    } catch (IOException e) {
      e.printStackTrace();
    }
  }

  /**
   *  Check whether a byte array starts with a given prefix.
   *  @param array  byte array to check
   *  @param prefix prefix to look for
   *  @return <code>true</code> if the byte array starts with the prefix,
   *          <code>false</code> otherwise
   */
  private static boolean startsWith(byte[] array, byte[] prefix)
  {
    if (array.length >= prefix.length) {
      for (int l = 0;  l < prefix.length;  ++l) {
        if (array[l] != prefix[l]) {
          return false;
        }
      }
      return true;
    }
    return false;
  }

  /**
   * Start thread.
   * @see Thread#run()
   */
  public void run()
  {
    try {
      int index = 0;
      while (true) {
        if (serverState != ServerState.GAME_OVER) {
          if (!bridgeMode) {
            sendKeys();
          }
          socket.receive(mameDatagram);
          long receiveTime = System.currentTimeMillis();
          if (mameDatagram.getLength() != MAME_DATAGRAM_SIZE) {
            // some other datagram, possibly only in online game
            byte[] data = mameDatagram.getData();
            if (startsWith(data, MAME_BUSY_PREFIX)) {
              String str = new String(data, MAME_BUSY_PREFIX.length, data.length - MAME_BUSY_PREFIX.length, "utf-8").trim();
              int frames = 0;
              try {
                frames = Integer.parseInt(str);
              } catch (NumberFormatException e) {
              }
              setServerState(ServerState.SERVER_BUSY);
              informServerStateListeners(frames);
              // inverse e^-1 rule
              if (frames > 180) {
                frames = (frames * 63) / 100;
              }
              // allow MAME some time to end game
              frames += 120;
              try {
                Thread.sleep((frames * 1000) / 60);
              } catch (InterruptedException e) {
              }
              setServerState(ServerState.UNKNOWN);
            }
            else if (startsWith(data, MAME_GAME_OVER_DATA)) {
              setServerState(ServerState.GAME_OVER);
            }
          }
          else {
            setServerState(ServerState.IN_GAME);
            informDatagramListeners(mameDatagram);
            FrameInfo fi = new FrameInfo(index++, mameDatagram.getData(), this, receiveTime);
            synchronized (pendingFrames) {
              if (!pendingFrames.isEmpty()) {
                if (pendingFrames.size() >= MAX_FRAMES_KEPT) {
                  pendingFrames.removeFirst();
                }
                if (true) {
                  byte lastId = pendingFrames.getLast().getId();
                  byte id     = fi.getId();
                  if ((byte)(id - lastId) != 1) {
                    System.err.println("Frame dropped? last="+lastId+", current="+id);
                  }
                }
              }
              pendingFrames.add(fi);
              if (framePreparer != null) {
                framePreparer.prepareFrames(pendingFrames);
              }
            }
            informFrameListeners(fi);
          }
        }
        else {
          // Server state is game over, which can only happen with online MAME.
          // We don't send anything more to avoid annoying the online MAME.

          // sleep a little while, after that the server state may have changed via the
          // restart() method.
          try {
            Thread.sleep(200);
          } catch (InterruptedException x) {
          }
        }
      }
    } catch (IOException e) {
      e.printStackTrace();
    }
  }

  /**
   *  Send the pressed keys.
   *
   *  @throws IOException if sending failed
   */
  private void sendKeys() throws IOException
  {
    synchronized (keyDatagram) {
      keyFrame[KEY_MASK_INDEX] = buttons.extractKeys();
      keyFrame[KEY_PING_INDEX] = (byte)ping;
      keyDatagram.setData(keyFrame);
      sendInfos[ping] = new SendInfo(keyFrame[KEY_MASK_INDEX], keyFrame[KEY_PING_INDEX]);
      socket.send(keyDatagram);
      informDatagramListeners(keyDatagram);
      latestPing = ping;
      if (++ping >= 256) {
        ping = 1;
      }
    }
  }

  /**
   *  Set a button.
   *  @param button the button (one of
   *                {@link #BUTTON_FIRE},
   *                {@link #BUTTON_HYPERSPACE},
   *                {@link #BUTTON_LEFT},
   *                {@link #BUTTON_RIGHT},
   *                {@link #BUTTON_THRUST}, and possibly
   *                {@link #BUTTON_START} (requires patched MAME version)
   *  @param down button down?
   *  @return the ping send with the button
   */
  public int setButton(int button, boolean down)
  {
    buttons.setKey(button, down);
    synchronized (keyDatagram) {
      return ping;
    }
  }

  /**
   *  Set a button down.
   *  @param button the button (one of
   *                {@link #BUTTON_FIRE},
   *                {@link #BUTTON_HYPERSPACE},
   *                {@link #BUTTON_LEFT},
   *                {@link #BUTTON_RIGHT},
   *                {@link #BUTTON_THRUST}, and possibly
   *                {@link #BUTTON_START} (requires patched MAME version))
   *  @return the ping send with the button
   */
  public int pushButton(int button)
  {
    return setButton(button, true);
  }

  /**
   *  Get the currently pressed buttons.
   *  @return button mask (a combination of
   *                {@link #BUTTON_FIRE},
   *                {@link #BUTTON_HYPERSPACE},
   *                {@link #BUTTON_LEFT},
   *                {@link #BUTTON_RIGHT},
   *                {@link #BUTTON_THRUST}, and possibly
   *                {@link #BUTTON_START} (requires patched MAME version))
   */
  public byte getButtons()
  {
    return buttons.getKeys();
  }


  /**
   *  Set the currently pressed buttons.
   *  @param mask button mask (a combination of
   *                {@link #BUTTON_FIRE},
   *                {@link #BUTTON_HYPERSPACE},
   *                {@link #BUTTON_LEFT},
   *                {@link #BUTTON_RIGHT},
   *                {@link #BUTTON_THRUST}, and possibly
   *                {@link #BUTTON_START} (requires patched MAME version))
   */
  public void setButtons(int mask)
  {
    buttons.setKeys(mask);
  }

  /**
   *  Add a frame listener which is called on every frame received.
   *  @param listener listener to add
   */
  public void addFrameListener(FrameListener listener)
  {
    synchronized (frameListeners) {
      frameListeners.add(listener);
    }
  }

  /**
   *  Remove a frame listener.
   *  @param listener listener to remove
   *  @return <code>true</code> if the listener was removed<br>
   *          <code>false</code> otherwise
   */
  public boolean removeFrameListener(FrameListener listener)
  {
    synchronized (frameListeners) {
      return frameListeners.remove(listener);
    }
  }

  /**
   *  Add a datagram listener which is called on every datagram received.
   *  @param listener listener to add
   */
  public void addDatagramListener(DatagramListener listener)
  {
    synchronized (datagramListeners) {
      datagramListeners.add(listener);
    }
  }

  /**
   *  Remove a datagram listener.
   *  @param listener listener to remove
   *  @return <code>true</code> if the listener was removed<br>
   *          <code>false</code> otherwise
   */
  public boolean removeDatagramListener(DatagramListener listener)
  {
    synchronized (datagramListeners) {
      return datagramListeners.remove(listener);
    }
  }

  /**
   *  Add a server state listener which is called when the server state changes or new wait frame information is received.
   *  @param listener listener to add
   */
  public void addServerStateListener(ServerStateListener listener)
  {
    synchronized (serverStateListeners) {
      serverStateListeners.add(listener);
    }
  }

  /**
   *  Remove a server state listener.
   *  @param listener listener to remove
   *  @return <code>true</code> if the listener was removed<br>
   *          <code>false</code> otherwise
   */
  public boolean removeServerStateListener(ServerStateListener listener)
  {
    synchronized (serverStateListeners) {
      return serverStateListeners.remove(listener);
    }
  }

  /**
   *  Inform all registered frame listeners.
   *  @param frameInfo frame info received
   */
  private void informFrameListeners(FrameInfo frameInfo)
  {
    Collection<FrameListener> tmp;
    synchronized (frameListeners) {
      if (frameListeners.isEmpty()) {
        return;
      }
      tmp = new ArrayList<FrameListener>(frameListeners);
    }
    for (FrameListener listener: tmp) {
      listener.frameReceived(frameInfo);
    }
  }

  /**
   *  Inform all registered datagram listeners.
   *  @param datagram datagram received
   */
  private void informDatagramListeners(DatagramPacket datagram)
  {
    Collection<DatagramListener> tmp;
    synchronized (datagramListeners) {
      if (datagramListeners.isEmpty()) {
        return;
      }
      tmp = new ArrayList<DatagramListener>(datagramListeners);
    }
    if (datagram.getLength() == MAME_DATAGRAM_SIZE) {
      for (DatagramListener listener: tmp) {
        listener.datagramReceived(datagram, this);
      }
    }
    else {
      for (DatagramListener listener: tmp) {
        listener.datagramSent(datagram);
      }
    }
  }

  /**
   *  Inform all registered server state listeners.
   *  @param oldState old server state
   *  @param newState current server state
   */
  private void informServerStateListeners(ServerState oldState, ServerState newState)
  {
    System.out.println("New server state: "+newState);
    Collection<ServerStateListener> tmp;
    synchronized (serverStateListeners) {
      if (serverStateListeners.isEmpty()) {
        return;
      }
      tmp = new ArrayList<ServerStateListener>(serverStateListeners);
    }
    for (ServerStateListener listener: tmp) {
      listener.serverStateChanged(oldState, newState);
    }
  }

  /**
   *  Inform all registered server state listeners.
   *  @param waitFrames new value of frames to wait
   */
  private void informServerStateListeners(int waitFrames)
  {
    System.out.println("waitFrames "+waitFrames);
    Collection<ServerStateListener> tmp;
    synchronized (serverStateListeners) {
      if (serverStateListeners.isEmpty()) {
        return;
      }
      tmp = new ArrayList<ServerStateListener>(serverStateListeners);
    }
    for (ServerStateListener listener: tmp) {
      listener.framesToWaitUpdate(waitFrames);
    }
  }


  /**
   *  Send a datagram packet.
   *  @param packet packet to send
   *  @throws IOException on send errors
   */
  public void sendDatagram(DatagramPacket packet) throws IOException
  {
    packet.setSocketAddress(mameAddr);
    socket.send(packet);
    informDatagramListeners(packet);
  }

  /**
   * Get the collected frames.
   *
   * This class collects only the last 256 frames, older frames are discarded.
   * @return the last frames in the sequence in which they were received (but not necessarily send)
   */
  public Collection<FrameInfo> getFrames()
  {
    synchronized (pendingFrames) {
      return new ArrayList<FrameInfo>(pendingFrames);
    }
  }

  /**
   *  Get all frames with a newer timestamp.
   *  @param timestamp timestamp
   *  @return collection of frames with a receive time newer than the given timestamp
   */
  public Collection<FrameInfo> getFramesAfter(long timestamp)
  {
    // NOTE:
    // This implementation assumes that usually the timestamp selects only
    // the last few frames. It assumes that the pending frames are in the sequence
    // in which they were received, so if you ever change that (e.g. because you
    // are reordering by sequence numbers) you have to reimplement this method!
    ArrayList<FrameInfo> tmpFrames;
    synchronized (pendingFrames) {
      if (pendingFrames.isEmpty()  ||  pendingFrames.getFirst().getReceiveTime() > timestamp) {
        return getFrames();
      }
      tmpFrames = new ArrayList<FrameInfo>(pendingFrames);
    }
    LinkedList<FrameInfo> result = new LinkedList<FrameInfo>();
    for (int f = tmpFrames.size() - 1;  f >= 0;  --f) {
      FrameInfo info = tmpFrames.remove(f);
      if (info.getReceiveTime() <= timestamp) {
        break;
      }
      result.add(0, info);
    }
    return result;
  }

  /**
   *  Get the frame preparer.
   *  @return frame preparer or <code>null</code> if there is no frame preparer
   */
  public FramePreparer getFramePreparer()
  {
    return framePreparer;
  }

  /**
   *  Set the frame preparer.
   *
   *  The frame preparer is called on each frame as it is received before the frame
   *  listeners are called. It is most useful to create connections between frames, e.g.
   *  to calculate velocities are make any other connections between objects in different
   *  frames. See {@link de.caff.asteroid.SimpleVelocityPreparer} for example.
   *  @param framePreparer frame peparer to set or <code>null</code> to unset frame preparing
   */
  public void setFramePreparer(FramePreparer framePreparer)
  {
    this.framePreparer = framePreparer;
  }

  /**
   *  Get the ping sent with the latest key datagram.
   *  @return latest ping
   */
  public int getLatestPing()
  {
    return latestPing;
  }

  /**
   * Get the buttons sent with a given ping, looking backwards from a given frame.
   *
   * @param frameNr frame counter
   * @param ping    ping
   * @return buttons sent with a given ping
   */
  public int getKeysForPing(int frameNr, int ping)
  {
    return isStillKnown(frameNr, ping) ? sendInfos[ping].getKeys() : NO_BUTTON;
  }

  /**
   * Get the timestamp when a given ping was send, looking backwards from a given frame.
   *
   * @param frameNr frame counter
   * @param ping    ping
   * @return timestamp when the ping was sent, or <code>0L</code> if the info is not longer available
   */
  public long getTimestampForPing(int frameNr, int ping)
  {
    return isStillKnown(frameNr, ping) ? sendInfos[ping].getTimestamp() : 0L;
  }

  /**
   * Is information about the given ping still known, looking backwards from a given frame?
   *
   * @param frameNr frame counter
   * @param ping    ping
   * @return the answer
   */
  public boolean isStillKnown(int frameNr, int ping)
  {
    if (!pendingFrames.isEmpty()) {
      int firstFrame = pendingFrames.getFirst().getIndex();
      int lastFrame = pendingFrames.getLast().getIndex();
      try {
        return (frameNr >= firstFrame  &&  frameNr <= lastFrame + 1  &&
                ping != NO_PING  &&  sendInfos[ping] != null);
      } catch (ArrayIndexOutOfBoundsException e) {
      }
    }
    return false;
  }

  /**
   *  Get the current server state.
   *  @return server state
   */
  public ServerState getServerState()
  {
    return serverState;
  }
}