// Copyright (c) 2010 The Chromium Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.

package org.chromium.sdk.tests.system;

import java.io.IOException;
import java.net.InetSocketAddress;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collection;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;

import org.chromium.sdk.Breakpoint;
import org.chromium.sdk.Browser;
import org.chromium.sdk.BrowserFactory;
import org.chromium.sdk.CallFrame;
import org.chromium.sdk.CallbackSemaphore;
import org.chromium.sdk.ConnectionLogger;
import org.chromium.sdk.ConnectionLogger.Factory;
import org.chromium.sdk.DebugContext;
import org.chromium.sdk.JavascriptVm;
import org.chromium.sdk.JsEvaluateContext;
import org.chromium.sdk.JsObject;
import org.chromium.sdk.JsScope;
import org.chromium.sdk.JsValue;
import org.chromium.sdk.JsVariable;
import org.chromium.sdk.RelayOk;
import org.chromium.sdk.Script;
import org.chromium.sdk.UnsupportedVersionException;
import org.chromium.sdk.internal.wip.WipBackendImpl;
import org.chromium.sdk.wip.WipBackend;
import org.chromium.sdk.wip.WipBackendFactory;
import org.chromium.sdk.wip.WipBrowser;
import org.chromium.sdk.wip.WipBrowserFactory;

/**
 * A small automatic test that connects to Chromium browser using ChromeDevTools SDK and try some
 * actions like setting a breakpoint or reading a value of a local variables.
 */
public class Main {

  private static final String TAB_URL_SUFFIX = "/main.html";
  private static final String SCRIPT_ONE_NAME = "/script1.js";
  private static final String BREAKPOINT_MARK = "#breakpoint#1#";
  private static final String FIBONACCI_EXPRESSION = "Fibonacci(4)";

  public static void main(String[] args) throws SmokeException {
    CommandLineArgs commandLineArgs = readArguments(args);

    InetSocketAddress address =
        new InetSocketAddress(commandLineArgs.getHost(), commandLineArgs.getPort());

    StateManager stateManager = new StateManager();

    stateManager.setDefaultReceiver(EXPECT_NOTHING_VISITOR);

    ConnectionLogger.Factory connectionLoggerFactory = new ConnectionLogger.Factory() {
      public ConnectionLogger newConnectionLogger() {
        return new SystemOutConnectionLogger();
      }
    };
    JavascriptVm vm;
    try {
      vm = commandLineArgs.getProtocolType().connect(address, stateManager,
          connectionLoggerFactory);
    } catch (IOException e) {
      throw new SmokeException("Failed to connect", e);
    }

    Collection<Script> scripts = loadScripts(vm);

    // Finding script1.js script.
    Script scriptOne;
    lookForScript: {
      for (Script script : scripts) {
        String name = script.getName();
        if (name != null && name.endsWith(SCRIPT_ONE_NAME)) {
          scriptOne = script;
          break lookForScript;
        }
      }
      throw new SmokeException("Failed to find script " + SCRIPT_ONE_NAME);
    }

    // Getting a number of the line with the marker.
    int breakLine = findSourceMark(scriptOne, BREAKPOINT_MARK);
    if (breakLine == -1) {
      throw new SmokeException("Failed to find mark in script");
    }

    // Setting a breakpoint.
    CallbackSemaphore callbackSemaphore = new CallbackSemaphore();
    Breakpoint.Target breakpointTarget = new Breakpoint.Target.ScriptName(scriptOne.getName());
    RelayOk relayOk = vm.setBreakpoint(breakpointTarget, breakLine, 0, true, null,
        null, callbackSemaphore);
    callbackSemaphore.acquireDefault(relayOk);

    // First time just suspend on breakpoint and go on.
    {
      DebugContext context = stateManager.expectEvent(EXPECT_SUSPENDED_VISITOR);
      context.continueVm(DebugContext.StepAction.CONTINUE, 0, null);
      stateManager.expectEvent(EXPECT_RESUMED_VISITOR);
    }

    // Second time check variables and expressions.
    {
      DebugContext context = stateManager.expectEvent(EXPECT_SUSPENDED_VISITOR);

      {
        // Check cache dropping.
        JsObject root = evaluateSync(context.getGlobalEvaluateContext(),
            "(debug_value_1 = {a:2})").getValue().asObject();
        if (root == null) {
          throw new RuntimeException();
        }
        String aValue;
        aValue = root.getProperty("a").getValue().getValueString();
        if (!"2".equals(aValue)) {
          throw new SmokeException();
        }
        evaluateSync(context.getGlobalEvaluateContext(), "debug_value_1.a = 3");

        root.getRemoteValueMapping().clearCaches();

        aValue = root.getProperty("a").getValue().getValueString();
        if (!"3".equals(aValue)) {
          throw new SmokeException();
        }
      }

      {
        // Check literals.
        for (LiteralTestCase literal : TEST_LITERALS) {
          JsVariable resultVar = evaluateSync(context.getGlobalEvaluateContext(),
              literal.javaScriptExpression);
          JsValue resultValue = resultVar.getValue();
          if (resultValue.getType() != literal.expectedType) {
            throw new SmokeException("Unexpected type of '" + literal.javaScriptExpression +
                "': " + resultValue.getType());
          }
          if (!literal.expectedDescription.equals(resultValue.getValueString())) {
            throw new SmokeException("Unexpected string value of '" +
                literal.javaScriptExpression + "': " + resultValue.getValueString());
          }
        }
      }

      // Do not block dispatcher thread.
      stateManager.setDefaultReceiver(IGNORE_SCRIPTS_VISITOR);

      List<? extends CallFrame> callFrames = context.getCallFrames();
      CallFrame topFrame = callFrames.get(0);

      JsScope localScope;
      findScope: {
        for (JsScope scope : topFrame.getVariableScopes()) {
          if (scope.getType() == JsScope.Type.LOCAL) {
            localScope = scope;
            break findScope;
          }
        }
        throw new SmokeException("Failed to find local scope");
      }

      JsVariable xVar = getVariable(localScope, "x");
      if (!"1".equals(xVar.getValue().getValueString())) {
        throw new SmokeException("Unexpected value of local variable");
      }
      JsVariable yVar = getVariable(localScope, "y");
      if (!"2".equals(yVar.getValue().getValueString())) {
        throw new SmokeException("Unexpected value of local variable");
      }

      for (CallFrame frame : callFrames) {
        checkExpression(frame);
      }

      context.continueVm(DebugContext.StepAction.CONTINUE, 0, null);
      stateManager.expectEvent(EXPECT_RESUMED_VISITOR);
    }

    stateManager.setDefaultReceiver(IGNORE_ALL_VISITOR);
    vm.detach();

    System.out.println("Test passed OK");
  }

  private interface CommandLineArgs {
    String getHost();
    int getPort();
    ProtocolType getProtocolType();
  }

  private static CommandLineArgs readArguments(String[] argv) {

    List<String> simpleArgs = new ArrayList<String>(2);
    Map<String, String> keyToValueMap = new LinkedHashMap<String, String>(1);
    int pos = 0;
    while (pos < argv.length) {
      String s = argv[pos];
      if (s.startsWith("--")) {
        s = s.substring(2);
        String key;
        String value;
        int eqPos = s.indexOf('=');
        if (eqPos == -1) {
          key = s;
          value = null;
        } else {
          key = s.substring(0, eqPos);
          value = s.substring(eqPos + 1);
        }
        keyToValueMap.put(key, value);
        pos++;
      } else {
        simpleArgs.add(s);
        pos++;
      }
    }

    if (simpleArgs.size() != 2) {
      throw new IllegalArgumentException("2 arguments expected: debug socket host and port");
    }

    class CommandLineArgsImpl implements CommandLineArgs {
      public String getHost() {
        return host;
      }
      public int getPort() {
        return port;
      }
      public ProtocolType getProtocolType() {
        return protocolType;
      }

      String host;
      int port;
      ProtocolType protocolType = ProtocolType.SHELL;
    }

    CommandLineArgsImpl result = new CommandLineArgsImpl();

    result.host = simpleArgs.get(0);
    String portStr = simpleArgs.get(1);
    result.port = Integer.parseInt(portStr);

    for (Map.Entry<String, String> entry : keyToValueMap.entrySet()) {
      String key = entry.getKey();
      if ("protocol".equals(key)) {
        result.protocolType = ProtocolType.valueOf(entry.getValue());
      } else {
        throw new IllegalArgumentException("Unknown parameter: " + key);
      }
    }

    return result;
  }

  private enum ProtocolType {
    // Old protocol enabled by --remote-shell-port parameter
    SHELL {
      @Override
      public JavascriptVm connect(InetSocketAddress address,
          StateManager stateManager, Factory connectionLoggerFactory)
          throws SmokeException, IOException {

        Browser browser = BrowserFactory.getInstance().create(address, connectionLoggerFactory);

        Browser.TabFetcher tabFetcher;
        try {
          tabFetcher = browser.createTabFetcher();
        } catch (UnsupportedVersionException e) {
          throw new SmokeException(e);
        }
        List<? extends Browser.TabConnector> tabs = tabFetcher.getTabs();
        if (tabs.isEmpty()) {
          throw new SmokeException("No tabs");
        }
        Browser.TabConnector firstTab = tabs.get(0);
        String url = firstTab.getUrl();
        if (url == null || !url.endsWith(TAB_URL_SUFFIX)) {
          throw new SmokeException("Unexpected URL: " + url);
        }
        return firstTab.attach(stateManager.getTabListener());
      }
    },
    // WIP (new) protocol enabled by --remote-debugging-port parameter
    DEBUGGING {
      @Override
      public JavascriptVm connect(InetSocketAddress address,
          StateManager stateManager, final Factory connectionLoggerFactory)
          throws SmokeException, IOException {
        WipBrowserFactory.LoggerFactory wipLoggerFactory = new WipBrowserFactory.LoggerFactory() {
          @Override public ConnectionLogger newBrowserConnectionLogger() {
            return connectionLoggerFactory.newConnectionLogger();
          }

          @Override public ConnectionLogger newTabConnectionLogger() {
            return connectionLoggerFactory.newConnectionLogger();
          }
        };
        WipBrowser browser = WipBrowserFactory.INSTANCE.createBrowser(address, wipLoggerFactory);

        WipBackend wipBackend = new WipBackendFactory().create();
        List<? extends WipBrowser.WipTabConnector> tabs;
        try {
          tabs = browser.getTabs(wipBackend);
        } catch (IOException e) {
          throw new SmokeException(e);
        }
        if (tabs.isEmpty()) {
          throw new SmokeException("No tabs");
        }
        WipBrowser.WipTabConnector firstTab = tabs.get(0);
        String url = firstTab.getUrl();
        if (url == null || !url.endsWith(TAB_URL_SUFFIX)) {
          throw new SmokeException("Unexpected URL: " + url);
        }
        return firstTab.attach(stateManager.getTabListener()).getJavascriptVm();
      }
    };

    public abstract JavascriptVm connect(InetSocketAddress address, StateManager stateManager,
        Factory connectionLoggerFactory) throws SmokeException, IOException;
  }

  private static JsVariable getVariable(JsScope scope, String name) throws SmokeException {
    for (JsVariable var : scope.getVariables()) {
      if (name.equals(var.getName())) {
        return var;
      }
    }
    throw new SmokeException("Failed to find variable " + name);
  }

  /**
   * Calls fibonacci expression in context for stack frame and checks the result value.
   */
  private static void checkExpression(CallFrame frame) throws SmokeException {
    final ValueHolder<JsVariable> variableHolder = new ValueHolder<JsVariable>();
    JsEvaluateContext.EvaluateCallback callback = new JsEvaluateContext.EvaluateCallback() {
      public void failure(String errorMessage) {
        variableHolder.setException(new Exception(errorMessage));
      }
      public void success(JsVariable variable) {
        variableHolder.setValue(variable);
      }
    };
    frame.getEvaluateContext().evaluateSync(FIBONACCI_EXPRESSION, null, callback);
    JsVariable variable = variableHolder.get();
    String resString = variable.getValue().getValueString();
    if (!"24".equals(resString)) {
      throw new SmokeException("Wrong expression value");
    }
  }

  private static class LiteralTestCase {
    final String javaScriptExpression;
    final JsValue.Type expectedType;
    final String expectedDescription;

    LiteralTestCase(String javaScriptExpression, JsValue.Type expectedType,
        String expectedDescription) {
      this.javaScriptExpression = javaScriptExpression;
      this.expectedType = expectedType;
      this.expectedDescription = expectedDescription;
    }
  }

  private static final List<LiteralTestCase> TEST_LITERALS = Arrays.asList(
      new LiteralTestCase("2011", JsValue.Type.TYPE_NUMBER, "2011"),
      new LiteralTestCase("0", JsValue.Type.TYPE_NUMBER, "0"),
      new LiteralTestCase("-5", JsValue.Type.TYPE_NUMBER, "-5"),
      new LiteralTestCase("123.4567", JsValue.Type.TYPE_NUMBER, "123.4567"),
      new LiteralTestCase("NaN", JsValue.Type.TYPE_NUMBER, "NaN"),
      new LiteralTestCase("Infinity", JsValue.Type.TYPE_NUMBER, "Infinity"),
      new LiteralTestCase("-Infinity", JsValue.Type.TYPE_NUMBER, "-Infinity"),
      new LiteralTestCase("null", JsValue.Type.TYPE_NULL, "null"),
      new LiteralTestCase("(void 0)", JsValue.Type.TYPE_UNDEFINED, "undefined"),
      new LiteralTestCase("true", JsValue.Type.TYPE_BOOLEAN, "true"),
      new LiteralTestCase("false", JsValue.Type.TYPE_BOOLEAN, "false"),
      new LiteralTestCase("'abc'", JsValue.Type.TYPE_STRING, "abc"),
      new LiteralTestCase("'\"'", JsValue.Type.TYPE_STRING, "\""),
      new LiteralTestCase("'\u0424'", JsValue.Type.TYPE_STRING, "\u0424"),
      new LiteralTestCase("'\0'", JsValue.Type.TYPE_STRING, "\0"),
      new LiteralTestCase("\"rrr\"", JsValue.Type.TYPE_STRING, "rrr"));

  /**
   * @return 1-base number of the source line containing marker or -1
   */
  private static int findSourceMark(Script script, String mark) {
    String source = script.getSource();
    int pos = 0;
    int line = 1;
    String rest;
    while (true) {
      int lineEnd = source.indexOf('\n', pos);
      if (lineEnd == -1) {
        rest = source.substring(lineEnd + 1);
        line++;
        break;
      }
      if (source.substring(pos, lineEnd).contains(mark)) {
        return line;
      }
      line++;
      pos = lineEnd + 1;
    }
    if (rest.contains(mark)) {
      return line;
    }
    return -1;
  }

  private static Collection<Script> loadScripts(JavascriptVm javascriptVm) throws SmokeException {
    final ValueHolder<Collection<Script>> result = new ValueHolder<Collection<Script>>();

    JavascriptVm.ScriptsCallback scriptsCallback = new JavascriptVm.ScriptsCallback() {
      public void failure(final String errorMessage) {
        result.setException(new Exception("Failed to read scripts: " + errorMessage));
      }

      public void success(final Collection<Script> scripts) {
        result.setValue(scripts);
      }
    };
    javascriptVm.getScripts(scriptsCallback);
    return result.get();
  }

  /**
   * Connection logger that simply prints all traffic out to System.out.
   * */
  private static class SystemOutConnectionLogger implements ConnectionLogger {
    public void handleEos() {
      System.out.println("EOS");
    }
    public void setConnectionCloser(ConnectionCloser connectionCloser) {
    }
    public void start() {
    }

    @Override
    public StreamListener getIncomingStreamListener() {
      return new StreamListenerImpl();
    }
    @Override
    public StreamListener getOutgoingStreamListener() {
      return new StreamListenerImpl();
    }

    private class StreamListenerImpl implements StreamListener {
      @Override
      public void addSeparator() {
        System.out.print("\n------------------\n");
      }

      @Override
      public void addContent(CharSequence sequence) {
        System.out.print(sequence);
      }
    }
  }

  private static class EventVisitorBase<RES> implements EventVisitor<RES> {
    public RES visitClosed() throws SmokeException {
      return handleDefault();
    }
    public RES visitDisconnected() throws SmokeException {
      return handleDefault();
    }
    public RES visitNavigated(String newUrl) throws SmokeException {
      return handleDefault();
    }
    public RES visitScriptLoaded(Script newScript) throws SmokeException {
      return handleDefault();
    }
    public RES visitScriptCollected(Script script) throws SmokeException {
      return handleDefault();
    }
    public RES visitSuspended(DebugContext context) throws SmokeException {
      return handleDefault();
    }
    public RES visitResumed() throws SmokeException {
      return handleDefault();
    }
    protected RES handleDefault() throws SmokeException {
      throw new SmokeException("Unexpected event");
    }
  }

  private static final EventVisitorBase<Void> EXPECT_NOTHING_VISITOR = new EventVisitorBase<Void>();

  /**
   * Do not pay attention to script afterCompile event.
   */
  private static class IgnoreScriptsVisitor<RES> extends EventVisitorBase<RES> {
    @Override
    public RES visitScriptLoaded(Script newScript) {
      return null;
    }
    @Override
    public RES visitScriptCollected(Script script) {
      return null;
    }
  }

  private static final IgnoreScriptsVisitor<Void> IGNORE_SCRIPTS_VISITOR =
      new IgnoreScriptsVisitor<Void>();

  private static final EventVisitor<DebugContext> EXPECT_SUSPENDED_VISITOR =
      new IgnoreScriptsVisitor<DebugContext>() {
        @Override
        public DebugContext visitSuspended(DebugContext context) {
          return context;
        }
     };

 private static final EventVisitor<Boolean> EXPECT_RESUMED_VISITOR =
     new IgnoreScriptsVisitor<Boolean>() {
       @Override
       public Boolean visitResumed() {
         return Boolean.TRUE;
       }
    };

  private static final EventVisitor<Void> IGNORE_ALL_VISITOR = new EventVisitorBase<Void>() {
    @Override
    protected Void handleDefault() {
      return null;
    }
  };

  /**
   * A utility class to pass a result from callback of async operation to a function that
   * is blocked waiting for this operation. Operation may complete with normal result value or
   * with exception.
   */
  private static class ValueHolder<T> {
    private T val = null;
    private Exception exception = null;

    void setValue(T val) {
      this.val = val;
    }
    void setException(Exception exception) {
      this.exception = exception;
    }
    T get() throws SmokeException {
      if (exception != null) {
        throw new SmokeException(exception);
      }
      return val;
    }
  }

  static class EvalCallbackImpl implements JsEvaluateContext.EvaluateCallback {
    JsVariable variable = null;
    String failure = null;
    @Override
    public void success(JsVariable variable) {
      this.variable = variable;
    }

    @Override
    public void failure(String errorMessage) {
      this.failure = errorMessage;
    }
    JsVariable get() {
      if (failure != null) {
        throw new RuntimeException("Failed to evaluate: " + failure);
      }
      return variable;
    }
  }

  private static JsVariable evaluateSync(JsEvaluateContext evaluateContext, String expression) {
    EvalCallbackImpl callbackImpl = new EvalCallbackImpl();
    evaluateContext.evaluateSync(expression, null, callbackImpl);
    return callbackImpl.get();
  }
}
