// Copyright (c) 2011 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.internal.wip;

import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.concurrent.atomic.AtomicInteger;

import org.chromium.sdk.JsEvaluateContext;
import org.chromium.sdk.JsVariable;
import org.chromium.sdk.RelayOk;
import org.chromium.sdk.SyncCallback;
import org.chromium.sdk.internal.wip.WipExpressionBuilder.ValueNameBuilder;
import org.chromium.sdk.internal.wip.WipRelayRunner.ProcessException;
import org.chromium.sdk.internal.wip.WipRelayRunner.Step;
import org.chromium.sdk.internal.wip.protocol.input.runtime.CallFunctionOnData;
import org.chromium.sdk.internal.wip.protocol.input.runtime.EvaluateData;
import org.chromium.sdk.internal.wip.protocol.input.runtime.RemoteObjectValue;
import org.chromium.sdk.internal.wip.protocol.output.WipParamsWithResponse;
import org.chromium.sdk.internal.wip.protocol.output.runtime.CallArgumentParam;
import org.chromium.sdk.internal.wip.protocol.output.runtime.CallFunctionOnParams;
import org.chromium.sdk.internal.wip.protocol.output.runtime.EvaluateParams;
import org.chromium.sdk.util.GenericCallback;
import org.chromium.sdk.util.RelaySyncCallback;

/**
 * Helper class that implements evaluate with additional context and
 * destination group id operation. This implementation is a hack because it adds (injects)
 * a property to the global object and works with its properties. The normal approach is when
 * the protocol itself supports this operation. As it hopefully will.
 */
public class EvaluateHack {

  private final WipTabImpl tabImpl;
  private final AtomicInteger uniqueIdCounter = new AtomicInteger(0);
  private boolean objectInjected = false;

  public EvaluateHack(WipTabImpl tabImpl) {
    this.tabImpl = tabImpl;
  }

  /**
   * Implements evaluate with additional context and destination group id operation.
   * The implementation modifies a global object.
   * @param destinationValueLoader value loader that corresponds to the destination group
   * @param evaluateCommandHandler provides a particular request type
   */
  public RelayOk evaluateAsync(String expression, ValueNameBuilder valueNameBuidler,
      Map<String, String> additionalContext,
      WipValueLoader destinationValueLoader, EvaluateCommandHandler<?> evaluateCommandHandler,
      final JsEvaluateContext.EvaluateCallback callback, SyncCallback syncCallback) {

    RelaySyncCallback relaySyncCallback = new RelaySyncCallback(syncCallback);

    final EvaluateSession evaluateSession = new EvaluateSession(expression, valueNameBuidler,
        additionalContext, destinationValueLoader, evaluateCommandHandler);

    final RelaySyncCallback.Guard guard = relaySyncCallback.newGuard();

    GenericCallback<Void> postEnsureCallback = new GenericCallback<Void>() {
          @Override
          public void success(Void value) {
            RelayOk relayOk = evaluateSession.run(callback, guard.getRelay());
            guard.discharge(relayOk);
          }

          @Override
          public void failure(Exception exception) {
            if (callback != null) {
              callback.failure(exception.getMessage());
            }
          }
        };

    return ensureObjectInjected(postEnsureCallback, guard.asSyncCallback());
  }

  /**
   * Provides an actual evaluate request. It may or may not refer to a particular call frame
   * or deal with other details that are out of scope of this class.
   *
   * @param <DATA> type of request's response
   */
  public interface EvaluateCommandHandler<DATA> {
    WipParamsWithResponse<DATA> createRequest(String patchedUserExpression,
        WipValueLoader destinationValueLoader);

    JsVariable processResult(DATA response, WipValueLoader destinationValueLoader,
        ValueNameBuilder valueNameBuidler);

    /**
     * Return the same exception or wraps it with a more high-level error details.
     * @return not null
     */
    Exception processFailure(Exception cause);
  }

  synchronized void pageReloaded() {
    objectInjected = false;
  }

  /**
   * Corresponds to a one evaluate operation. Holds most of parameters. It does following:
   * <ol>
   *   <li>creates a temporary object inside the main injected object,
   *   <li>puts all values from additional context thus making it a 'with' object,
   *   <li>evaluates user expression inside the 'with' operator,
   *   <li>returns result to a user callback,
   *   <li>deletes the temporary object.
   * </ol>
   *
   * It uses {@link WipRelayRunner} as an engine.
   */
  private class EvaluateSession {
    private final String userExpression;
    private final ValueNameBuilder valueNameBuidler;
    private final Map<String, String> additionalContext;
    private final WipValueLoader destinationValueLoader;
    private final EvaluateCommandHandler<?> evaluateCommandHandler;

    private final String dataId = "d" + uniqueIdCounter.incrementAndGet();

    EvaluateSession(String expression, ValueNameBuilder valueNameBuidler,
        Map<String, String> additionalContext,
        WipValueLoader destinationValueLoader, EvaluateCommandHandler<?> evaluateCommandHandler) {
      this.userExpression = expression;
      this.valueNameBuidler = valueNameBuidler;
      this.additionalContext = additionalContext;
      this.destinationValueLoader = destinationValueLoader;
      this.evaluateCommandHandler = evaluateCommandHandler;
    }

    RelayOk run(final JsEvaluateContext.EvaluateCallback callback, RelaySyncCallback relay) {
      WipRelayRunner.Step<JsVariable> step = createFillDataObjectStep();

      GenericCallback<JsVariable> innerCallback;
      if (callback == null) {
        innerCallback = null;
      } else {
        innerCallback = new GenericCallback<JsVariable>() {
          @Override public void success(JsVariable value) {
            callback.success(value);
          }
          @Override public void failure(Exception exception) {
            callback.failure(exception.getMessage());
          }
        };
      }

      return WipRelayRunner.run(tabImpl.getCommandProcessor(), step,
          innerCallback, relay);
    }

    /**
     * Sends request that create a temporary object and fills it with user values.
     * User values are passed as 1. 'this', 2. additional arguments to the function.
     */
    private WipRelayRunner.Step<JsVariable> createFillDataObjectStep() {
      if (additionalContext.isEmpty()) {
        throw new IllegalArgumentException("Empty context");
      }

      StringBuilder assigmentBuilder = new StringBuilder();
      StringBuilder parametersBuilder = new StringBuilder();

      boolean isFirst = true;
      String thisObjectId = null;
      final List<String> additionalObjectIds = new ArrayList<String>(0);
      String tempObjectRef = GLOBAL_VARIABLE_NAME + ".data." + dataId + ".";
      for (Map.Entry<String, String> entry : additionalContext.entrySet()) {
        String commandParamName;
        if (isFirst) {
          commandParamName = "this";
          thisObjectId = entry.getValue();
          isFirst = false;
        } else {
          commandParamName = "p" + additionalObjectIds.size();
          additionalObjectIds.add(entry.getValue());
          if (parametersBuilder.length() != 0) {
            parametersBuilder.append(", ");
          }
          parametersBuilder.append(commandParamName);
        }
        assigmentBuilder.append(tempObjectRef + entry.getKey() + " = " + commandParamName + ";\n");
      }

      final String functionText = "function(" + parametersBuilder + ") { " +
          GLOBAL_VARIABLE_NAME + ".data." + dataId + " = {};\n" +
          assigmentBuilder + "}";

      final String thisObjectIdFinal = thisObjectId;

      return new WipRelayRunner.SendStepWithResponse<CallFunctionOnData, JsVariable>() {
        @Override
        public WipParamsWithResponse<CallFunctionOnData> getParams() {
          List<CallArgumentParam> arguments;
          if (additionalObjectIds.isEmpty()) {
            arguments = null;
          } else {
            arguments = new ArrayList<CallArgumentParam>(additionalObjectIds.size());
            for (String objectId : additionalObjectIds) {
              arguments.add(new CallArgumentParam(null, objectId));
            }
          }
          return new CallFunctionOnParams(thisObjectIdFinal, functionText, arguments, true);
        }

        @Override
        public Step<JsVariable> processResponse(CallFunctionOnData response) {
          if (response.wasThrown() == Boolean.TRUE) {
            return createHandleErrorStep(response.result());
          }
          return createEvaluateStep(evaluateCommandHandler);
        }

        @Override
        public Exception processFailure(Exception cause) {
          return cause;
        }
      };
    }

    private <EVAL_DATA> WipRelayRunner.Step<JsVariable> createEvaluateStep(
        final EvaluateCommandHandler<EVAL_DATA> commandHandler) {
      return new WipRelayRunner.SendStepWithResponse<EVAL_DATA, JsVariable>() {
        @Override
        public WipParamsWithResponse<EVAL_DATA> getParams() {
          String script = "with (" + GLOBAL_VARIABLE_NAME + ".data." + dataId +
              ") { return (" + userExpression + "); }";
          String wrappedExpression = "(function() {" + script +"})()";

          WipParamsWithResponse<EVAL_DATA> paramsWithResponse = commandHandler.createRequest(
              wrappedExpression, destinationValueLoader);

          return paramsWithResponse;
        }

        @Override
        public Step<JsVariable> processResponse(EVAL_DATA response) {
          JsVariable jsVariable =
              commandHandler.processResult(response, destinationValueLoader, valueNameBuidler);

          clearTempObjectAsync();

          return WipRelayRunner.createFinalStep(jsVariable);
        }

        @Override
        public Exception processFailure(Exception cause) {
          return commandHandler.processFailure(cause);
        }
      };
    }

    /**
     * Clears the temporary object. It is done asynchronously, outside the main relay, because
     * user shouldn't wait for its result.
     */
    private void clearTempObjectAsync() {
      String script = "delete " + GLOBAL_VARIABLE_NAME + ".data." + dataId + ";";
      String deleteDataExpression = "(function() {" + script +"})()";
      EvaluateParams evaluateParams =
          new EvaluateParams(deleteDataExpression, null, null, null, null, true);
      tabImpl.getCommandProcessor().send(evaluateParams, (WipCommandCallback) null, null);
    }

    /**
     * An alternative spin-off in the relay, that handles an exception we ran into.
     * The additional step is needed because the exception message is only available from
     * its 'message' pseudo-property (a getter).
     */
    private Step<JsVariable> createHandleErrorStep(final RemoteObjectValue remoteObjectValue) {
      return new WipRelayRunner.SendStepWithResponse<CallFunctionOnData, JsVariable>() {
        @Override
        public WipParamsWithResponse<CallFunctionOnData> getParams() {
          String functionText = "function() { return String(this.message); }";
          return new CallFunctionOnParams(remoteObjectValue.objectId(), functionText, null, true);
        }

        @Override
        public Step<JsVariable> processResponse(CallFunctionOnData response)
            throws ProcessException {
          throw new ProcessException("Helper script failed on remote: " +
              response.result().value());
        }

        @Override
        public Exception processFailure(Exception cause) {
          return cause;
        }
      };
    }
  }

  /**
   * Makes sure that we injected a helper object inside a global object.
   * This cannot be implemented as step in {@link WipRelayRunner}, because the method
   * is synchronized and cannot undergo required control inversion.
   */
  private synchronized RelayOk ensureObjectInjected(GenericCallback<Void> callback,
      SyncCallback syncCallback) {
    if (objectInjected) {
      callback.success(null);
      return RelaySyncCallback.finish(syncCallback);
    } else {
      objectInjected = true;
      return injectObject(callback, syncCallback);
    }
  }

  private RelayOk injectObject(final GenericCallback<Void> callback, SyncCallback syncCallback) {
    // 'data' is for temporary objects.
    // 'code' is for utility methods.
    String injectedObjectText = "{ data: {}, code: {}}";
    String expression = "(function() { " + GLOBAL_VARIABLE_NAME + " = " + injectedObjectText +
        " ; })()";

    EvaluateParams evaluateParams = new EvaluateParams(expression, null, false, null, null, true);

    GenericCallback<EvaluateData> wrappedCallback = new GenericCallback<EvaluateData>() {
      @Override
      public void success(EvaluateData value) {
        // TODO: check result.
        callback.success(null);
      }

      @Override
      public void failure(Exception exception) {
        callback.failure(new Exception("Failed to inject evaluate helper script into remote VM",
            exception));
      }
    };

    return tabImpl.getCommandProcessor().send(evaluateParams, wrappedCallback, syncCallback);
  }

  private static final String GLOBAL_VARIABLE_NAME = "_com_chromium_debug_helper";
}
