/*
 * Copyright 2008 Google Inc.
 *
 * Licensed under the Apache License, Version 2.0 (the "License"); you may not
 * use this file except in compliance with the License. You may obtain a copy of
 * the License at
 *
 * http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
 * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
 * License for the specific language governing permissions and limitations under
 * the License.
 */
package com.google.gwt.dev.shell;

import com.google.gwt.dev.shell.BrowserChannel.SessionHandler.ExceptionOrReturnValue;
import com.google.gwt.dev.shell.BrowserChannel.SessionHandler.SpecialDispatchId;
import com.google.gwt.dev.shell.BrowserChannel.Value.ValueType;
import com.google.gwt.thirdparty.guava.common.io.Closeables;

import java.io.BufferedInputStream;
import java.io.BufferedOutputStream;
import java.io.DataInputStream;
import java.io.DataOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.io.UnsupportedEncodingException;
import java.net.Socket;
import java.util.Set;

/**
 * A base class for communication between Dev-mode client and server.
 */
public abstract class BrowserChannel {

  /**
   * An error indicating that the remote side died and we should unroll the
   * call stack as painlessly as possible to allow cleanup.
   */
  public static class RemoteDeathError extends Error {

    public RemoteDeathError(Throwable cause) {
      super("Remote connection lost", cause);
    }
  }

  /**
   * Class representing a reference to a Java object.
   */
  public static class JavaObjectRef implements RemoteObjectRef {
    private int refId;

    public JavaObjectRef(int refId) {
      this.refId = refId;
    }

    @Override
    public int getRefid() {
      return Math.abs(refId);
    }

    @Override
    public int hashCode() {
      return refId;
    }

    public boolean isException() {
      return refId < 0;
    }

    @Override
    public String toString() {
      return "JavaObjectRef(ref=" + refId + ")";
    }
  }

  /**
   * Class representing a reference to a JS object.
   */
  public static class JsObjectRef implements RemoteObjectRef  {

    private int refId;

    public JsObjectRef(int refId) {
      this.refId = refId;
    }

    @Override
    public boolean equals(Object o) {
      return (o instanceof JsObjectRef) && ((JsObjectRef) o).refId == refId;
    }

    @Override
    public int getRefid() {
      // exceptions are negative, so we get the absolute value
      return Math.abs(refId);
    }

    @Override
    public int hashCode() {
      return refId;
    }

    public boolean isException() {
      return refId < 0;
    }

    @Override
    public String toString() {
      return "JsObjectRef(" + refId + ")";
    }
  }

  /**
   * Enumeration of message type ids.
   *
   * <p>Ids are used instead of relying on the ordinal to avoid sychronization
   * problems with the client.
   */
  public enum MessageType {
    /**
     * A message to invoke a method on the other side of the wire.  Note that
     * the messages are asymmetric -- see {@link InvokeOnClientMessage} and
     * {@link InvokeOnServerMessage}.
     */
    INVOKE(0),

    /**
     * Returns the result of an INVOKE, INVOKE_SPECIAL, or LOAD_MODULE message.
     */
    RETURN(1),

    /**
     * v1 LOAD_MODULE message.
     */
    OLD_LOAD_MODULE(2),

    /**
     * Normal closure of the connection.
     */
    QUIT(3),

    /**
     * A request by the server to load JSNI source into the client's JS engine.
     */
    LOAD_JSNI(4),

    INVOKE_SPECIAL(5),

    FREE_VALUE(6),

    /**
     * Abnormal termination of the connection.
     */
    FATAL_ERROR(7),

    CHECK_VERSIONS(8),

    PROTOCOL_VERSION(9),

    CHOOSE_TRANSPORT(10),

    SWITCH_TRANSPORT(11),

    LOAD_MODULE(12),

    REQUEST_ICON(13),

    USER_AGENT_ICON(14),

    REQUEST_PLUGIN(15);

    private final int id;

    private MessageType(int id) {
      this.id = id;
    }

    public int getId() {
      return id;
    }
  }

  /**
   * Represents an object on the other side of the channel, known to this side
   * by an reference ID.
   */
  public interface RemoteObjectRef {

    /**
      * @return the reference ID for this object.
      */
    int getRefid();
  }

  /**
   * Hook interface for responding to messages.
   *
   * @param <T> the BrowserChannel type this implementation is built to handle.
   */
  public abstract static class SessionHandler<T extends BrowserChannel> {

    /**
     * Wrapper to return both a return value/exception and a flag as to whether
     * an exception was thrown or not.
     */
    public static class ExceptionOrReturnValue {
      private final boolean isException;
      private final Value returnValue;

      public ExceptionOrReturnValue(boolean isException, Value returnValue) {
        this.isException = isException;
        this.returnValue = returnValue;
      }

      public Value getReturnValue() {
        return returnValue;
      }

      public boolean isException() {
        return isException;
      }
    }

    /**
     * Enumeration of dispatch IDs on object 0 (the ServerMethods object).
     *
     * <p>Ids are set specifically rather than relying on the ordinal to avoid
     * synchronization problems with the client.
     *
     * TODO: hasMethod/hasProperty no longer used, remove them!
     */
    public enum SpecialDispatchId {
      HasMethod(0), HasProperty(1), GetProperty(2), SetProperty(3);

      private final int id;

      private SpecialDispatchId(int id) {
        this.id = id;
      }

      public int getId() {
        return id;
      }
    }

    public abstract void freeValue(T channel, int[] ids);
  }

  /**
   * Represents a value for BrowserChannel.
   */
  public static class Value {
    /**
     * Enum of type tags sent across the wire.
     */
    public enum ValueType {
      /**
       * Primitive values.
       */
      NULL(0), BOOLEAN(1), BYTE(2), CHAR(3), SHORT(4), INT(5), LONG_UNUSED(6),
      FLOAT_UNUSED(7), DOUBLE(8), STRING(9),

      /**
       * Representations of Java or JS objects, sent as an index into a table
       * kept on the side holding the actual object.
       */
      JAVA_OBJECT(10), JS_OBJECT(11),

      /**
       * A Javascript undef value, also used for void returns.
       */
      UNDEFINED(12);

      private final int id;

      private ValueType(int id) {
        this.id = id;
      }

      byte getTag() {
        return (byte) id;
      }
    }

    /**
     * Type tag value.
     */
    private ValueType type = ValueType.UNDEFINED;

    /**
     * Represents a value sent/received across the wire.
     */
    private Object value = null;

    public Value() {
    }

    public Value(Object obj) {
      convertFromJavaValue(obj);
    }

    /**
     * Convert a Java object to a value. Objects must be primitive wrappers,
     * Strings, or JsObjectRef/JavaObjectRef instances.
     *
     * @param obj value to convert.
     */
    public void convertFromJavaValue(Object obj) {
      if (obj == null) {
        type = ValueType.NULL;
      } else if (obj instanceof Boolean) {
        type = ValueType.BOOLEAN;
      } else if (obj instanceof Byte) {
        type = ValueType.BYTE;
      } else if (obj instanceof Character) {
        type = ValueType.CHAR;
      } else if (obj instanceof Double) {
        type = ValueType.DOUBLE;
      } else if (obj instanceof Integer) {
        type = ValueType.INT;
      } else if (obj instanceof Short) {
        type = ValueType.SHORT;
      } else if (obj instanceof String) {
        type = ValueType.STRING;
      } else if (obj instanceof JsObjectRef) {
        // TODO: exception handling?
        type = ValueType.JS_OBJECT;
      } else if (obj instanceof JavaObjectRef) {
        // TODO: exception handling?
        type = ValueType.JAVA_OBJECT;
      } else {
        throw new IllegalArgumentException("Unexpected type: " + obj.getClass());
      }
      value = obj;
    }

    public boolean getBoolean() {
      assert type == ValueType.BOOLEAN;
      return ((Boolean) value).booleanValue();
    }

    public byte getByte() {
      assert type == ValueType.BYTE;
      return ((Byte) value).byteValue();
    }

    public char getChar() {
      assert type == ValueType.CHAR;
      return ((Character) value).charValue();
    }

    public double getDouble() {
      assert type == ValueType.DOUBLE;
      return ((Double) value).doubleValue();
    }

    public int getInt() {
      assert type == ValueType.INT;
      return ((Integer) value).intValue();
    }

    public JavaObjectRef getJavaObject() {
      assert type == ValueType.JAVA_OBJECT;
      return (JavaObjectRef) value;
    }

    public JsObjectRef getJsObject() {
      assert type == ValueType.JS_OBJECT;
      return (JsObjectRef) value;
    }

    public short getShort() {
      assert type == ValueType.SHORT;
      return ((Short) value).shortValue();
    }

    public String getString() {
      assert type == ValueType.STRING;
      return (String) value;
    }

    public ValueType getType() {
      return type;
    }

    public Object getValue() {
      return value;
    }

    public boolean isBoolean() {
      return type == ValueType.BOOLEAN;
    }

    public boolean isByte() {
      return type == ValueType.BYTE;
    }

    public boolean isChar() {
      return type == ValueType.CHAR;
    }

    public boolean isDouble() {
      return type == ValueType.DOUBLE;
    }

    public boolean isInt() {
      return type == ValueType.INT;
    }

    public boolean isJavaObject() {
      return type == ValueType.JAVA_OBJECT;
    }

    public boolean isJsObject() {
      return type == ValueType.JS_OBJECT;
    }

    public boolean isNull() {
      return type == ValueType.NULL;
    }

    public boolean isNumber() {
      switch (type) {
        case BYTE:
        case CHAR:
        case DOUBLE:
        case INT:
        case SHORT:
          return true;
        default:
          return false;
      }
    }

    public boolean isPrimitive() {
      switch (type) {
        case BOOLEAN:
        case BYTE:
        case CHAR:
        case DOUBLE:
        case INT:
        case SHORT:
          return true;
        default:
          return false;
      }
    }

    public boolean isShort() {
      return type == ValueType.SHORT;
    }

    public boolean isString() {
      return type == ValueType.STRING;
    }

    public boolean isUndefined() {
      return type == ValueType.UNDEFINED;
    }

    public void setBoolean(boolean val) {
      type = ValueType.BOOLEAN;
      value = Boolean.valueOf(val);
    }

    public void setByte(byte val) {
      type = ValueType.BYTE;
      value = Byte.valueOf(val);
    }

    public void setChar(char val) {
      type = ValueType.CHAR;
      value = Character.valueOf(val);
    }

    public void setDouble(double val) {
      type = ValueType.DOUBLE;
      value = Double.valueOf(val);
    }

    public void setInt(int val) {
      type = ValueType.INT;
      value = Integer.valueOf(val);
    }

    public void setJavaObject(JavaObjectRef val) {
      type = ValueType.JAVA_OBJECT;
      value = val;
    }

    public void setJsObject(JsObjectRef val) {
      type = ValueType.JS_OBJECT;
      value = val;
    }

    public void setLong(long val) {
      type = ValueType.BOOLEAN;
      value = Long.valueOf(val);
    }

    public void setNull() {
      type = ValueType.NULL;
      value = null;
    }

    public void setShort(short val) {
      type = ValueType.SHORT;
      value = Short.valueOf(val);
    }

    public void setString(String val) {
      type = ValueType.STRING;
      value = val;
    }

    public void setUndefined() {
      type = ValueType.UNDEFINED;
      value = null;
    }

    @Override
    public String toString() {
      return type + ": " + value;
    }
  }

  /**
   * The initial request from the client, supplies a range of supported versions
   * and the version from hosted.html (so stale copies on an external server
   * can be detected).
   */
  protected static class CheckVersionsMessage extends Message {

    public static CheckVersionsMessage receive(BrowserChannel channel)
        throws IOException {
      DataInputStream stream = channel.getStreamFromOtherSide();
      int minVersion = stream.readInt();
      int maxVersion = stream.readInt();
      String hostedHtmlVersion = readUtf8String(stream);
      return new CheckVersionsMessage(channel, minVersion, maxVersion,
          hostedHtmlVersion);
    }

    private final String hostedHtmlVersion;

    private final int maxVersion;

    private final int minVersion;

    public CheckVersionsMessage(BrowserChannel channel, int minVersion,
        int maxVersion, String hostedHtmlVersion) {
      super(channel);
      this.minVersion = minVersion;
      this.maxVersion = maxVersion;
      this.hostedHtmlVersion = hostedHtmlVersion;
    }

    public String getHostedHtmlVersion() {
      return hostedHtmlVersion;
    }

    public int getMaxVersion() {
      return maxVersion;
    }

    public int getMinVersion() {
      return minVersion;
    }

    @Override
    public void send() throws IOException {
      DataOutputStream stream = getBrowserChannel().getStreamToOtherSide();
      stream.writeByte(MessageType.CHECK_VERSIONS.getId());
      stream.writeInt(minVersion);
      stream.writeInt(maxVersion);
      writeUtf8String(stream, hostedHtmlVersion);
      stream.flush();
    }
  }

  /**
   * A message from the client giving a list of supported connection methods
   * and requesting the server choose one of them to switch protocol traffic to.
   */
  protected static class ChooseTransportMessage extends Message {

    public static ChooseTransportMessage receive(BrowserChannel channel)
        throws IOException {
      DataInputStream stream = channel.getStreamFromOtherSide();
      int n = stream.readInt();
      String[] transports = new String[n];
      for (int i = 0; i < n; ++i) {
        transports[i] = readUtf8String(stream);
      }
      return new ChooseTransportMessage(channel, transports);
    }

    private final String[] transports;

    public ChooseTransportMessage(BrowserChannel channel,
        String[] transports) {
      super(channel);
      this.transports = transports;
    }

    public String[] getTransports() {
      return transports;
    }

    @Override
    public void send() throws IOException {
      DataOutputStream stream = getBrowserChannel().getStreamToOtherSide();
      stream.writeByte(MessageType.CHOOSE_TRANSPORT.getId());
      stream.writeInt(transports.length);
      for (String transport : transports) {
        writeUtf8String(stream, transport);
      }
    }
  }

  /**
   * A message reporting a connection error to the client.
   */
  protected static class FatalErrorMessage extends Message {

    public static FatalErrorMessage receive(BrowserChannel channel)
        throws IOException {
      DataInputStream stream = channel.getStreamFromOtherSide();
      // NOTE: Tag has already been read.
      String error = readUtf8String(stream);
      return new FatalErrorMessage(channel, error);
    }

    private final String error;

    public FatalErrorMessage(BrowserChannel channel, String error) {
      super(channel);
      this.error = error;
    }

    public String getError() {
      return error;
    }

    @Override
    public void send() throws IOException {
      DataOutputStream stream = getBrowserChannel().getStreamToOtherSide();
      stream.writeByte(MessageType.FATAL_ERROR.getId());
      writeUtf8String(stream, error);
    }
  }

  /**
   * A message asking the other side to free object references. Note that there
   * is no response to this message, and this must only be sent immediately
   * before an Invoke or Return message.
   */
  protected static class FreeMessage extends Message {
    public static FreeMessage receive(BrowserChannel channel)
        throws IOException {
      DataInputStream stream = channel.getStreamFromOtherSide();
      int numIds = stream.readInt();
      // TODO: sanity check id count
      int ids[] = new int[numIds];
      for (int i = 0; i < numIds; ++i) {
        ids[i] = stream.readInt();
      }
      return new FreeMessage(channel, ids);
    }

    public static void send(BrowserChannel channel, int[] ids)
        throws IOException {
      DataOutputStream stream = channel.getStreamToOtherSide();
      stream.writeByte(MessageType.FREE_VALUE.getId());
      stream.writeInt(ids.length);
      for (int id : ids) {
        stream.writeInt(id);
      }
      stream.flush();
    }

    private final int ids[];

    public FreeMessage(BrowserChannel channel, int[] ids) {
      super(channel);
      this.ids = ids;
    }

    public int[] getIds() {
      return ids;
    }

    @Override
    public boolean isAsynchronous() {
      return true;
    }

    @Override
    public void send() throws IOException {
      send(getBrowserChannel(), ids);
    }
  }

  /**
   * A request from the server to invoke a function on the client.
   *
   * Note that MessageType.INVOKE can refer to either this class
   * or {@link InvokeOnServerMessage} depending on the direction, as the
   * protocol is asymmetric (Java needs a dispatch ID, Javascript needs a
   * name).
   */
  protected static class InvokeOnClientMessage extends Message {
    public static InvokeOnClientMessage receive(BrowserChannel channel)
        throws IOException {
      DataInputStream stream = channel.getStreamFromOtherSide();
      // NOTE: Tag has already been read.
      String methodName = readUtf8String(stream);
      Value thisRef = channel.readValue(stream);
      int argLen = stream.readInt();
      Value[] args = new Value[argLen];
      for (int i = 0; i < argLen; i++) {
        args[i] = channel.readValue(stream);
      }
      return new InvokeOnClientMessage(channel, methodName, thisRef, args);
    }

    private final Value[] args;
    private final String methodName;
    private final Value thisRef;

    public InvokeOnClientMessage(BrowserChannel channel, String methodName,
        Value thisRef, Value[] args) {
      super(channel);
      this.thisRef = thisRef;
      this.methodName = methodName;
      this.args = args;
    }

    public Value[] getArgs() {
      return args;
    }

    public String getMethodName() {
      return methodName;
    }

    public Value getThis() {
      return thisRef;
    }

    @Override
    public void send() throws IOException {
      final DataOutputStream stream = getBrowserChannel().getStreamToOtherSide();

      stream.writeByte(MessageType.INVOKE.getId());
      writeUtf8String(stream, methodName);
      getBrowserChannel().writeValue(stream, thisRef);
      stream.writeInt(args.length);
      for (int i = 0; i < args.length; i++) {
        getBrowserChannel().writeValue(stream, args[i]);
      }
      stream.flush();
    }
  }

  /**
   * A request from the client to invoke a function on the server.
   *
   * Note that MessageType.INVOKE can refer to either this class
   * or {@link InvokeOnClientMessage} depending on the direction, as the
   * protocol is asymmetric (Java needs a dispatch ID, Javascript needs a
   * name).
   */
  protected static class InvokeOnServerMessage extends Message {
    public static InvokeOnServerMessage receive(BrowserChannel channel)
        throws IOException {
      DataInputStream stream = channel.getStreamFromOtherSide();
      // NOTE: Tag has already been read.
      int methodDispatchId = stream.readInt();
      Value thisRef = channel.readValue(stream);
      int argLen = stream.readInt();
      Value[] args = new Value[argLen];
      for (int i = 0; i < argLen; i++) {
        args[i] = channel.readValue(stream);
      }
      return new InvokeOnServerMessage(channel, methodDispatchId, thisRef,
          args);
    }

    private final Value[] args;
    private final int methodDispatchId;
    private final Value thisRef;

    public InvokeOnServerMessage(BrowserChannel channel, int methodDispatchId,
        Value thisRef, Value[] args) {
      super(channel);
      this.thisRef = thisRef;
      this.methodDispatchId = methodDispatchId;
      this.args = args;
    }

    public Value[] getArgs() {
      return args;
    }

    public int getMethodDispatchId() {
      return methodDispatchId;
    }

    public Value getThis() {
      return thisRef;
    }

    @Override
    public void send() throws IOException {
      final DataOutputStream stream = getBrowserChannel().getStreamToOtherSide();

      stream.writeByte(MessageType.INVOKE.getId());
      stream.writeInt(methodDispatchId);
      getBrowserChannel().writeValue(stream, thisRef);
      stream.writeInt(args.length);
      for (int i = 0; i < args.length; i++) {
        getBrowserChannel().writeValue(stream, args[i]);
      }
      stream.flush();
    }
  }

  /**
   * A request from the to invoke a function on the other side.
   */
  protected static class InvokeSpecialMessage extends Message {
    public static InvokeSpecialMessage receive(BrowserChannel channel)
        throws IOException, BrowserChannelException {
      final DataInputStream stream = channel.getStreamFromOtherSide();
      // NOTE: Tag has already been read.
      final int specialMethodInt = stream.readByte();
      SpecialDispatchId[] ids = SpecialDispatchId.values();
      if (specialMethodInt < 0 || specialMethodInt >= ids.length) {
        throw new BrowserChannelException("Invalid dispatch id "
            + specialMethodInt);
      }
      final SpecialDispatchId dispatchId = ids[specialMethodInt];
      final int argLen = stream.readInt();
      final Value[] args = new Value[argLen];
      for (int i = 0; i < argLen; i++) {
        args[i] = channel.readValue(stream);
      }
      return new InvokeSpecialMessage(channel, dispatchId, args);
    }

    private final Value[] args;
    private final SpecialDispatchId dispatchId;

    public InvokeSpecialMessage(BrowserChannel channel,
        SpecialDispatchId dispatchId, Value[] args) {
      super(channel);
      this.dispatchId = dispatchId;
      this.args = args;
    }

    public Value[] getArgs() {
      return args;
    }

    public SpecialDispatchId getDispatchId() {
      return dispatchId;
    }

    @Override
    public void send() throws IOException {
      final DataOutputStream stream = getBrowserChannel().getStreamToOtherSide();

      stream.writeByte(MessageType.INVOKE_SPECIAL.getId());
      stream.writeByte(dispatchId.getId());
      stream.writeInt(args.length);
      for (int i = 0; i < args.length; i++) {
        getBrowserChannel().writeValue(stream, args[i]);
      }
      stream.flush();
    }
  }

  /**
   * A message sending JSNI code to be evaluated. Note that there is no response
   * to this message, and this must only be sent immediately before an Invoke or
   * Return message.
   */
  protected static class LoadJsniMessage extends Message {
    public static LoadJsniMessage receive(BrowserChannel channel)
        throws IOException {
      DataInputStream stream = channel.getStreamFromOtherSide();
      String js = readUtf8String(stream);
      return new LoadJsniMessage(channel, js);
    }

    public static void send(BrowserChannel channel, String js)
        throws IOException {
      DataOutputStream stream = channel.getStreamToOtherSide();
      stream.write(MessageType.LOAD_JSNI.getId());
      writeUtf8String(stream, js);
      stream.flush();
    }

    private final String js;

    public LoadJsniMessage(BrowserChannel channel, String js) {
      super(channel);
      this.js = js;
    }

    public String getJsni() {
      return js;
    }

    @Override
    public boolean isAsynchronous() {
      return true;
    }

    @Override
    public void send() throws IOException {
      send(getBrowserChannel(), js);
    }
  }

  /**
   * A request from the client that the server load and initialize a given
   * module.
   */
  protected static class LoadModuleMessage extends Message {
    public static LoadModuleMessage receive(BrowserChannel channel)
        throws IOException {
      DataInputStream stream = channel.getStreamFromOtherSide();
      String url = readUtf8String(stream);
      String tabKey = readUtf8String(stream);
      String sessionKey = readUtf8String(stream);
      String moduleName = readUtf8String(stream);
      String userAgent = readUtf8String(stream);
      return new LoadModuleMessage(channel, url, tabKey, sessionKey, moduleName,
          userAgent);
    }

    private final String moduleName;

    private final String sessionKey;

    private final String tabKey;

    private final String url;

    private final String userAgent;

    /**
     * Creates a LoadModule message to be sent to the server.
     *
     * @param channel BrowserChannel instance
     * @param url URL of main top-level window - may not be null
     * @param tabKey opaque key identifying the tab in the browser, or an
     *     empty string if it cannot be determined - may not be null
     * @param sessionKey opaque key identifying a particular session (ie,
     *     group of modules) - may not be null
     * @param moduleName name of GWT module to load - may not be null
     * @param userAgent user agent identifier of the browser - may not be null
     */
    public LoadModuleMessage(BrowserChannel channel, String url,
        String tabKey, String sessionKey, String moduleName, String userAgent) {
      super(channel);
      assert url != null;
      assert tabKey != null;
      assert sessionKey != null;
      assert moduleName != null;
      assert userAgent != null;
      this.url = url;
      this.tabKey = tabKey;
      this.sessionKey = sessionKey;
      this.moduleName = moduleName;
      this.userAgent = userAgent;
    }

    public String getModuleName() {
      return moduleName;
    }

    public String getSessionKey() {
      return sessionKey;
    }

    public String getTabKey() {
      return tabKey;
    }

    public String getUrl() {
      return url;
    }

    public String getUserAgent() {
      return userAgent;
    }

    @Override
    public void send() throws IOException {
      DataOutputStream stream = getBrowserChannel().getStreamToOtherSide();
      stream.writeByte(MessageType.LOAD_MODULE.getId());
      writeUtf8String(stream, url);
      writeUtf8String(stream, tabKey);
      writeUtf8String(stream, sessionKey);
      writeUtf8String(stream, moduleName);
      writeUtf8String(stream, userAgent);
      stream.flush();
    }
  }

  /**
   * Abstract base class of OOPHM messages.
   */
  protected abstract static class Message {
    public static MessageType readMessageType(DataInputStream stream)
        throws IOException, BrowserChannelException {
      stream.mark(1);
      int type = stream.readByte();
      MessageType[] types = MessageType.values();
      if (type < 0 || type >= types.length) {
        stream.reset();
        throw new BrowserChannelException("Invalid message type " + type);
      }
      return types[type];
    }

    private final BrowserChannel channel;

    public Message(BrowserChannel channel) {
      this.channel = channel;
    }

    public final BrowserChannel getBrowserChannel() {
      return channel;
    }

    /**
     * @return true if this message type is asynchronous and does not expect a
     *         return message.
     */
    public boolean isAsynchronous() {
      return false;
    }

    /**
     * @throws IOException if a subclass encounters an I/O error
     */
    public void send() throws IOException {
      throw new UnsupportedOperationException(getClass().getName()
          + " is a message format that can only be received.");
    }
  }

  /**
   * Provides a way of allocating JS and Java object ids without knowing
   * which one is the remote type, so code can be shared between client and
   * server.
   */
  protected interface ObjectRefFactory {

    JavaObjectRef getJavaObjectRef(int refId);

    JsObjectRef getJsObjectRef(int refId);

    Set<Integer> getRefIdsForCleanup();
  }

  /**
   * A request from the client that the server load and initialize a given
   * module (original v1 version).
   */
  protected static class OldLoadModuleMessage extends Message {
    public static OldLoadModuleMessage receive(BrowserChannel channel)
        throws IOException {
      DataInputStream stream = channel.getStreamFromOtherSide();
      int protoVersion = stream.readInt();
      String moduleName = readUtf8String(stream);
      String userAgent = readUtf8String(stream);
      return new OldLoadModuleMessage(channel, protoVersion, moduleName,
          userAgent);
    }

    private final String moduleName;

    private final int protoVersion;

    private final String userAgent;

    public OldLoadModuleMessage(BrowserChannel channel, int protoVersion,
        String moduleName, String userAgent) {
      super(channel);
      this.protoVersion = protoVersion;
      this.moduleName = moduleName;
      this.userAgent = userAgent;
    }

    public String getModuleName() {
      return moduleName;
    }

    public int getProtoVersion() {
      return protoVersion;
    }

    public String getUserAgent() {
      return userAgent;
    }

    @Override
    public void send() throws IOException {
      DataOutputStream stream = getBrowserChannel().getStreamToOtherSide();
      stream.writeByte(MessageType.OLD_LOAD_MODULE.getId());
      stream.writeInt(protoVersion);
      writeUtf8String(stream, moduleName);
      writeUtf8String(stream, userAgent);
      stream.flush();
    }
  }

  /**
   * Reports the selected protocol version.
   */
  protected static class ProtocolVersionMessage extends Message {

    public static ProtocolVersionMessage receive(BrowserChannel channel)
        throws IOException {
      DataInputStream stream = channel.getStreamFromOtherSide();
      int protocolVersion = stream.readInt();
      return new ProtocolVersionMessage(channel, protocolVersion);
    }

    private final int protocolVersion;

    public ProtocolVersionMessage(BrowserChannel channel, int protocolVersion) {
      super(channel);
      this.protocolVersion = protocolVersion;
    }

    public int getProtocolVersion() {
      return protocolVersion;
    }

    @Override
    public void send() throws IOException {
      DataOutputStream stream = getBrowserChannel().getStreamToOtherSide();
      stream.writeByte(MessageType.PROTOCOL_VERSION.getId());
      stream.writeInt(protocolVersion);
      stream.flush();
    }
  }

  /**
   * A message signifying a soft close of the communications channel.
   */
  protected static class QuitMessage extends Message {
    public static QuitMessage receive(BrowserChannel channel) {
      return new QuitMessage(channel);
    }

    public static void send(BrowserChannel channel) throws IOException {
      final DataOutputStream stream = channel.getStreamToOtherSide();
      stream.writeByte(MessageType.QUIT.getId());
      stream.flush();
    }

    public QuitMessage(BrowserChannel channel) {
      super(channel);
    }

    @Override
    public void send() throws IOException {
      send(getBrowserChannel());
    }
  }

  /**
   * A message asking the client to send an icon suitable for use in the UI.
   * <p>See {@link UserAgentIconMessage}.
   */
  protected static class RequestIconMessage extends Message {

    /**
     * Receive a RequestIconMessage, assuming the message tag has already been
     * read.
     *
     * @throws IOException
     */
    public static RequestIconMessage receive(BrowserChannel channel)
        throws IOException {
      return new RequestIconMessage(channel);
    }

    public static void send(BrowserChannel channel)
        throws IOException {
      DataOutputStream stream = channel.getStreamToOtherSide();
      stream.writeByte(MessageType.REQUEST_ICON.getId());
      stream.flush();
    }

    public RequestIconMessage(BrowserChannel channel) {
      super(channel);
    }

    @Override
    public void send() throws IOException {
      send(getBrowserChannel());
    }
  }

  /**
   * Signifies a return from a previous invoke.
   */
  protected static class ReturnMessage extends Message {
    public static ReturnMessage receive(BrowserChannel channel)
        throws IOException {
      final DataInputStream stream = channel.getStreamFromOtherSide();
      final boolean isException = stream.readBoolean();
      final Value returnValue = channel.readValue(stream);
      return new ReturnMessage(channel, isException, returnValue);
    }

    public static void send(BrowserChannel channel, boolean isException,
        Value returnValue) throws IOException {
      final DataOutputStream stream = channel.getStreamToOtherSide();
      stream.writeByte(MessageType.RETURN.getId());
      stream.writeBoolean(isException);
      channel.writeValue(stream, returnValue);
      stream.flush();
    }

    public static void send(BrowserChannel channel,
        ExceptionOrReturnValue returnOrException) throws IOException {
      send(channel, returnOrException.isException(),
          returnOrException.getReturnValue());
    }

    private final boolean isException;
    private final Value returnValue;

    public ReturnMessage(BrowserChannel channel, boolean isException,
        Value returnValue) {
      super(channel);
      this.returnValue = returnValue;
      this.isException = isException;
    }

    public Value getReturnValue() {
      return returnValue;
    }

    public boolean isException() {
      return isException;
    }

    @Override
    public void send() throws IOException {
      send(getBrowserChannel(), isException, returnValue);
    }
  }

  /**
   * A response to ChooseTransport telling the client which transport should
   * be used for the remainder of the protocol.
   */
  protected static class SwitchTransportMessage extends Message {

    public static SwitchTransportMessage receive(BrowserChannel channel)
        throws IOException {
      DataInputStream stream = channel.getStreamFromOtherSide();
      String transport = readUtf8String(stream);
      String transportArgs = readUtf8String(stream);
      return new SwitchTransportMessage(channel, transport, transportArgs);
    }

    private final String transport;

    private final String transportArgs;

    public SwitchTransportMessage(BrowserChannel channel,
        String transport, String transportArgs) {
      super(channel);
      // Change nulls to empty strings
      if (transport == null) {
        transport = "";
      }
      if (transportArgs == null) {
        transportArgs = "";
      }
      this.transport = transport;
      this.transportArgs = transportArgs;
    }

    public String getTransport() {
      return transport;
    }

    public String getTransportArgs() {
      return transportArgs;
    }

    @Override
    public void send() throws IOException {
      DataOutputStream stream = getBrowserChannel().getStreamToOtherSide();
      stream.writeByte(MessageType.SWITCH_TRANSPORT.getId());
      writeUtf8String(stream, transport);
      writeUtf8String(stream, transportArgs);
      stream.flush();
    }
  }

  /**
   * A message supplying an icon, which fits in 24x24 and in a standard image
   * format such as PNG or GIF, suitable for use in the UI.
   * <p>See {@link RequestIconMessage}.
   */
  protected static class UserAgentIconMessage extends Message {
    public static UserAgentIconMessage receive(BrowserChannel channel)
        throws IOException {
      byte[] iconBytes = null;
      DataInputStream stream = channel.getStreamFromOtherSide();
      int len = stream.readInt();
      if (len > 0) {
        iconBytes = new byte[len];
        for (int i = 0; i < len; ++i) {
          iconBytes[i] = stream.readByte();
        }
      }
      return new UserAgentIconMessage(channel, iconBytes);
    }

    public static void send(BrowserChannel channel, byte[] iconBytes)
        throws IOException {
      DataOutputStream stream = channel.getStreamToOtherSide();
      stream.writeByte(MessageType.USER_AGENT_ICON.getId());
      if (iconBytes == null) {
        stream.writeInt(0);
      } else {
        stream.writeInt(iconBytes.length);
        for (byte b : iconBytes) {
          stream.writeByte(b);
        }
      }
      stream.flush();
    }

    private byte[] iconBytes;

    public UserAgentIconMessage(BrowserChannel channel, byte[] iconBytes) {
      super(channel);
      this.iconBytes = iconBytes;
    }

    public byte[] getIconBytes() {
      return iconBytes;
    }

    @Override
    public void send() throws IOException {
      send(getBrowserChannel(), iconBytes);
    }
  }

  /**
   * The current version of the protocol.
   */
  public static final int PROTOCOL_VERSION_CURRENT = 3;

  /**
   * The oldest protocol version supported by this code.
   */
  public static final int PROTOCOL_VERSION_OLDEST = 2;

  /**
   * The protocol version that added the GetIcon message.
   */
  public static final int PROTOCOL_VERSION_GET_ICON = 3;

  public static final int SPECIAL_CLIENTMETHODS_OBJECT = 0;

  public static final int SPECIAL_SERVERMETHODS_OBJECT = 0;

  protected static JavaObjectRef getJavaObjectRef(int refId) {
    return new JavaObjectRef(refId);
  }

  protected static String readUtf8String(DataInputStream stream)
      throws IOException {
    final int len = stream.readInt();
    final byte[] data = new byte[len];
    stream.readFully(data);
    return new String(data, "UTF8");
  }

  protected static ValueType readValueType(DataInputStream stream)
      throws IOException, BrowserChannelException {
    int type = stream.readByte();
    ValueType[] types = ValueType.values();
    if (type < 0 || type >= types.length) {
      throw new BrowserChannelException("Invalid value type " + type);
    }
    return types[type];
  }

  protected static void writeJavaObject(DataOutputStream stream,
      JavaObjectRef value) throws IOException {
    stream.writeByte(ValueType.JAVA_OBJECT.getTag());
    stream.writeInt(value.getRefid());
  }

  protected static void writeJsObject(DataOutputStream stream,
      JsObjectRef value) throws IOException {
    stream.writeByte(ValueType.JS_OBJECT.getTag());
    stream.writeInt(value.getRefid());
  }

  protected static void writeNull(DataOutputStream stream) throws IOException {
    stream.writeByte(ValueType.NULL.getTag());
  }

  protected static void writeTaggedBoolean(DataOutputStream stream,
      boolean value) throws IOException {
    stream.writeByte(ValueType.BOOLEAN.getTag());
    stream.writeBoolean(value);
  }

  protected static void writeTaggedByte(DataOutputStream stream, byte value)
      throws IOException {
    stream.writeByte(ValueType.BYTE.getTag());
    stream.writeByte(value);
  }

  protected static void writeTaggedChar(DataOutputStream stream, char value)
      throws IOException {
    stream.writeByte(ValueType.CHAR.getTag());
    stream.writeChar(value);
  }

  protected static void writeTaggedDouble(DataOutputStream stream, double value)
      throws IOException {
    stream.writeByte(ValueType.DOUBLE.getTag());
    stream.writeDouble(value);
  }

  protected static void writeTaggedInt(DataOutputStream stream, int value)
      throws IOException {
    stream.writeByte(ValueType.INT.getTag());
    stream.writeInt(value);
  }

  protected static void writeTaggedShort(DataOutputStream stream, short value)
      throws IOException {
    stream.writeByte(ValueType.SHORT.getTag());
    stream.writeShort(value);
  }

  protected static void writeTaggedString(DataOutputStream stream, String data)
      throws IOException {
    stream.writeByte(ValueType.STRING.getTag());
    writeUtf8String(stream, data);
  }

  protected static void writeUtf8String(DataOutputStream stream, String data)
      throws IOException {
    try {
      final byte[] bytes = data.getBytes("UTF8");
      stream.writeInt(bytes.length);
      stream.write(bytes);
    } catch (UnsupportedEncodingException e) {
      // TODO: Add description.
      throw new RuntimeException();
    }
  }

  private static void writeUndefined(DataOutputStream stream)
      throws IOException {
    stream.writeByte(ValueType.UNDEFINED.getTag());
  }

  private final ObjectRefFactory objectRefFactory;

  private Socket socket;

  private final DataInputStream streamFromOtherSide;

  private final DataOutputStream streamToOtherSide;

  public BrowserChannel(Socket socket, ObjectRefFactory objectRefFactory)
      throws IOException {
    this(new BufferedInputStream(socket.getInputStream()),
        new BufferedOutputStream(socket.getOutputStream()),
        objectRefFactory);
    this.socket = socket;
  }

  protected BrowserChannel(InputStream inputStream, OutputStream outputStream,
      ObjectRefFactory objectRefFactory) {
    streamFromOtherSide = new DataInputStream(inputStream);
    streamToOtherSide = new DataOutputStream(outputStream);
    socket = null;
    this.objectRefFactory = objectRefFactory;
  }

  public void endSession() {
    Closeables.closeQuietly(streamFromOtherSide);
    try {
      streamToOtherSide.close();
    } catch (IOException ignored) {
      // ignore exception, we just want to close everything regardless of errors
    }
    if (socket != null) {
      try {
        socket.close();
      } catch (IOException ignored) {
        // ignore exception, we just want to close everything regardless of errors
      }
    }
  }

  /**
   * @return a set of remote object reference IDs to be freed.
   */
  public Set<Integer> getRefIdsForCleanup() {
    return objectRefFactory.getRefIdsForCleanup();
  }

  public String getRemoteEndpoint() {
    if (socket == null) {
      return "";
    }
    return socket.getInetAddress().getCanonicalHostName() + ":"
        + socket.getPort();
  }

  protected DataInputStream getStreamFromOtherSide() {
    return streamFromOtherSide;
  }

  protected DataOutputStream getStreamToOtherSide() {
    return streamToOtherSide;
  }

  protected Value readValue(DataInputStream stream) throws IOException {
    ValueType tag;
    try {
      tag = readValueType(stream);
    } catch (BrowserChannelException e) {
      IOException ee = new IOException();
      ee.initCause(e);
      throw ee;
    }
    Value value = new Value();
    switch (tag) {
      case NULL:
        value.setNull();
        break;
      case UNDEFINED:
        value.setUndefined();
        break;
      case BOOLEAN:
        value.setBoolean(stream.readByte() != 0);
        break;
      case BYTE:
        value.setByte(stream.readByte());
        break;
      case CHAR:
        value.setChar(stream.readChar());
        break;
      case INT:
        value.setInt(stream.readInt());
        break;
      case DOUBLE:
        value.setDouble(stream.readDouble());
        break;
      case SHORT:
        value.setShort(stream.readShort());
        break;
      case STRING:
        value.setString(readUtf8String(stream));
        break;
      case JS_OBJECT:
        value.setJsObject(objectRefFactory.getJsObjectRef(stream.readInt()));
        break;
      case JAVA_OBJECT:
        value.setJavaObject(objectRefFactory.getJavaObjectRef(
            stream.readInt()));
        break;
      default:
        throw new IllegalArgumentException("Unexpected type: " + tag);
    }
    return value;
  }

  protected void sendFreedValues() throws IOException {
    Set<Integer> freed = objectRefFactory.getRefIdsForCleanup();
    int n = freed.size();
    if (n > 0) {
      int[] ids = new int[n];
      int i = 0;
      for (Integer id : freed) {
        ids[i++] = id;
      }
      FreeMessage.send(this, ids);
    }
  }

  protected void writeValue(DataOutputStream stream, Value value)
      throws IOException {
    if (value.isNull()) {
      writeNull(stream);
    } else if (value.isUndefined()) {
      writeUndefined(stream);
    } else if (value.isJsObject()) {
      writeJsObject(stream, value.getJsObject());
    } else if (value.isJavaObject()) {
      writeJavaObject(stream, value.getJavaObject());
    } else if (value.isBoolean()) {
      writeTaggedBoolean(stream, value.getBoolean());
    } else if (value.isByte()) {
      writeTaggedByte(stream, value.getByte());
    } else if (value.isChar()) {
      writeTaggedChar(stream, value.getChar());
    } else if (value.isShort()) {
      writeTaggedShort(stream, value.getShort());
    } else if (value.isDouble()) {
      writeTaggedDouble(stream, value.getDouble());
    } else if (value.isInt()) {
      writeTaggedInt(stream, value.getInt());
    } else if (value.isString()) {
      writeTaggedString(stream, value.getString());
    } else {
      throw new IllegalArgumentException("Unexpected type: " + value.getType());
    }
  }
}
