// Copyright 2012 Google Inc. All Rights Reserved.
//
// 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.collide.client.code.debugging;

import collide.client.util.CssUtils;
import collide.client.util.Elements;

import com.google.collide.client.code.debugging.DebuggerApiTypes.ConsoleMessage;
import com.google.collide.client.code.debugging.DebuggerApiTypes.ConsoleMessageLevel;
import com.google.collide.client.code.debugging.DebuggerApiTypes.ConsoleMessageType;
import com.google.collide.client.code.debugging.DebuggerApiTypes.RemoteObject;
import com.google.collide.client.code.debugging.DebuggerApiTypes.RemoteObjectSubType;
import com.google.collide.client.code.debugging.DebuggerApiTypes.RemoteObjectType;
import com.google.collide.client.code.debugging.DebuggerApiTypes.StackTraceItem;
import com.google.collide.client.util.dom.DomUtils;
import com.google.collide.json.client.JsoArray;
import com.google.collide.json.shared.JsonArray;
import com.google.collide.mvp.CompositeView;
import com.google.collide.mvp.UiComponent;
import com.google.collide.shared.util.JsonCollections;
import com.google.collide.shared.util.StringUtils;
import com.google.gwt.resources.client.ClientBundle;
import com.google.gwt.resources.client.CssResource;
import com.google.gwt.resources.client.ImageResource;

import elemental.dom.Element;
import elemental.events.Event;
import elemental.events.EventListener;
import elemental.html.AnchorElement;

/**
 * Console View.
 *
 */
public class ConsoleView extends UiComponent<ConsoleView.View> {

  public interface Css extends CssResource {
    String root();
    String consoleMessages();
    String messageRoot();
    String messageLink();
    String repeatCountBubble();
    String consoleObject();
    String consolePrimitiveValue();
    String consoleDebugLevel();
    String consoleErrorLevel();
    String consoleLogLevel();
    String consoleTipLevel();
    String consoleWarningLevel();
    String consoleStackTrace();
    String consoleStackTraceCollapsed();
    String consoleStackTraceExpanded();
    String consoleStackTraceController();
    String consoleStackTraceItem();
  }

  interface Resources extends ClientBundle,
      RemoteObjectTree.Resources,
      RemoteObjectNodeRenderer.Resources {
    @Source("ConsoleView.css")
    Css workspaceEditorConsoleViewCss();

    @Source("errorIcon.png")
    ImageResource errorIcon();

    @Source("warningIcon.png")
    ImageResource warningIcon();

    @Source("triangleRight.png")
    ImageResource triangleRight();

    @Source("triangleDown.png")
    ImageResource triangleDown();
  }

  /**
   * Listener of this pane's events.
   */
  interface Listener {
    void onLocationLinkClick(String url, int lineNumber);
  }

  /**
   * The view for the sidebar call stack pane.
   */
  static class View extends CompositeView<ViewEvents> {
    private final Css css;
    private final Resources resources;
    private final RemoteObjectNodeRenderer nodeRenderer;
    private final JsonArray<RemoteObjectTree> remoteObjectTrees = JsonCollections.createArray();

    private final Element consoleMessages;

    private final EventListener clickListener = new EventListener() {
      @Override
      public void handleEvent(Event evt) {
        Element target = (Element) evt.getTarget();
        if (target.hasClassName(css.messageLink())) {
          evt.preventDefault();
          evt.stopPropagation();

          AnchorElement anchor = (AnchorElement) target;
          int lineNumber = 0;
          String anchorText = anchor.getTextContent();
          int pos = anchorText.lastIndexOf(':');
          if (pos != -1) {
            try {
              lineNumber = (int) Double.parseDouble(anchorText.substring(pos + 1));
              // Safe convert from one-based number to zero-based.
              lineNumber = Math.max(0, lineNumber - 1);
            } catch (NumberFormatException e) {
              // Ignore.
            }
          }
          getDelegate().onLocationLinkClick(anchor.getHref(), lineNumber);
        } else if (target.hasClassName(css.consoleStackTraceController())) {
          Element messageRoot = CssUtils.getAncestorOrSelfWithClassName(target, css.messageRoot());
          if (messageRoot != null) {
            boolean expanded = messageRoot.hasClassName(css.consoleStackTraceExpanded());
            CssUtils.setClassNameEnabled(messageRoot, css.consoleStackTraceExpanded(), !expanded);
            CssUtils.setClassNameEnabled(messageRoot, css.consoleStackTraceCollapsed(), expanded);
          }
        }
      }
    };

    View(Resources resources) {
      this.resources = resources;
      css = resources.workspaceEditorConsoleViewCss();
      nodeRenderer = new RemoteObjectNodeRenderer(resources);

      consoleMessages = Elements.createDivElement(css.consoleMessages());
      consoleMessages.addEventListener(Event.CLICK, clickListener, false);

      Element rootElement = Elements.createDivElement(css.root());
      rootElement.appendChild(consoleMessages);
      setElement(rootElement);
    }

    private void appendConsoleMessage(ConsoleMessage message, DebuggerState debuggerState) {
      boolean forceObjectFormat = false;
      JsonArray<RemoteObject> parameters;
      if (message.getType() != null) {
        switch (message.getType()) {
          case TRACE:
            // Discard all parameters.
            parameters = JsoArray.from(DebuggerApiUtils.createRemoteObject("console.trace()"));
            break;
          case ASSERT:
            parameters = JsoArray.from(DebuggerApiUtils.createRemoteObject("Assertion failed:"));
            parameters.addAll(message.getParameters());
            break;
          case DIR:
          case DIRXML:
            // Use only first parameter, if any.
            parameters = message.getParameters().size() > 0 ?
                message.getParameters().slice(0, 1) :
                JsoArray.from(DebuggerApiTypes.UNDEFINED_REMOTE_OBJECT);
            forceObjectFormat = true;
            break;
          case ENDGROUP:
            // TODO: Support console.group*() some day.
            // Until then, just ignore the console.groupEnd().
            return;
          default:
            parameters = message.getParameters();
            break;
        }
      } else {
        parameters = message.getParameters();
      }

      if (parameters.size() == 0) {
        if (StringUtils.isNullOrEmpty(message.getText())) {
          parameters = JsoArray.from(DebuggerApiTypes.UNDEFINED_REMOTE_OBJECT);
        } else {
          parameters = JsoArray.from(DebuggerApiUtils.createRemoteObject(message.getText()));
        }
      }

      Element messageElement = Elements.createDivElement(css.messageRoot());

      // Add message link first.
      JsonArray<StackTraceItem> stackTrace = message.getStackTrace();
      StackTraceItem topFrame = stackTrace.isEmpty() ? null : stackTrace.get(0);
      if (topFrame != null && !StringUtils.isNullOrEmpty(topFrame.getUrl())) {
        messageElement.appendChild(formatLocationLink(
            topFrame.getUrl(), topFrame.getLineNumber(), topFrame.getColumnNumber()));
      } else if (!StringUtils.isNullOrEmpty(message.getUrl())) {
        messageElement.appendChild(formatLocationLink(
            message.getUrl(), message.getLineNumber(), 0));
      }

      // Add the Stack Trace expand/collapse controller.
      final boolean shouldDisplayStackTrace = !stackTrace.isEmpty() &&
          (message.getType() == ConsoleMessageType.TRACE ||
              message.getLevel() == ConsoleMessageLevel.ERROR);
      if (shouldDisplayStackTrace) {
        if (ConsoleMessageType.TRACE.equals(message.getType())) {
          messageElement.addClassName(css.consoleStackTraceExpanded());
        } else {
          messageElement.addClassName(css.consoleStackTraceCollapsed());
        }
        messageElement.appendChild(Elements.createSpanElement(css.consoleStackTraceController()));
      }

      // Add all message arguments.
      for (int i = 0, n = parameters.size(); i < n; ++i) {
        if (i > 0) {
          messageElement.appendChild(Elements.createTextNode(" "));
        }
        messageElement.appendChild(
            formatRemoteObjectInConsole(parameters.get(i), debuggerState, forceObjectFormat));
      }

      if (message.getLevel() != null) {
        switch (message.getLevel()) {
          case DEBUG:
            messageElement.addClassName(css.consoleDebugLevel());
            break;
          case ERROR:
            messageElement.addClassName(css.consoleErrorLevel());
            break;
          case LOG:
            messageElement.addClassName(css.consoleLogLevel());
            break;
          case TIP:
            messageElement.addClassName(css.consoleTipLevel());
            break;
          case WARNING:
            messageElement.addClassName(css.consoleWarningLevel());
            break;
        }
      }

      if (shouldDisplayStackTrace) {
        messageElement.appendChild(formatStackTrace(stackTrace));
      }

      updateConsoleMessageCount(messageElement, message.getRepeatCount());
      consoleMessages.appendChild(messageElement);
    }

    private void updateLastConsoleMessageCount(int repeatCount) {
      Element messageElement = (Element) consoleMessages.getLastChild();
      if (messageElement == null) {
        return;
      }
      updateConsoleMessageCount(messageElement, repeatCount);
    }

    private void updateConsoleMessageCount(Element messageElement, int repeatCount) {
      Element repeatCountElement = DomUtils.getFirstElementByClassName(
          messageElement, css.repeatCountBubble());
      if (repeatCountElement == null) {
        if (repeatCount > 1) {
          repeatCountElement = Elements.createSpanElement(css.repeatCountBubble());
          repeatCountElement.setTextContent(Integer.toString(repeatCount));
          messageElement.insertBefore(repeatCountElement, messageElement.getFirstChild());
        } else {
          // Do nothing.
        }
      } else {
        if (repeatCount > 1) {
          repeatCountElement.setTextContent(Integer.toString(repeatCount));
        } else {
          repeatCountElement.removeFromParent();
        }
      }
    }

    private Element formatRemoteObjectInConsole(RemoteObject remoteObject,
        DebuggerState debuggerState, boolean forceObjectFormat) {
      if (forceObjectFormat && remoteObject.hasChildren()) {
        return formatRemoteObjectInConsoleAsObject(remoteObject, debuggerState);
      }

      RemoteObjectType type = remoteObject.getType();
      RemoteObjectSubType subType = remoteObject.getSubType();
      if (type == RemoteObjectType.OBJECT && (subType == null ||
          subType == RemoteObjectSubType.ARRAY || subType == RemoteObjectSubType.NODE)) {
        // TODO: Display small ARRAYs inlined.
        // TODO: Display NODE objects as XML tree some day.
        return formatRemoteObjectInConsoleAsObject(remoteObject, debuggerState);
      }

      Element messageElement = Elements.createSpanElement(css.consolePrimitiveValue());
      if (!RemoteObjectType.STRING.equals(type)) {
        String className = nodeRenderer.getTokenClassName(remoteObject);
        if (!StringUtils.isNullOrEmpty(className)) {
          messageElement.addClassName(className);
        }
      }
      messageElement.setTextContent(remoteObject.getDescription());
      return messageElement;
    }

    private Element formatRemoteObjectInConsoleAsObject(RemoteObject remoteObject,
        DebuggerState debuggerState) {
      RemoteObjectTree remoteObjectTree =
          RemoteObjectTree.create(new RemoteObjectTree.View(resources), resources, debuggerState);
      remoteObjectTrees.add(remoteObjectTree);

      RemoteObjectNode newRoot = RemoteObjectNode.createRoot();
      RemoteObjectNode child = new RemoteObjectNode.Builder("", remoteObject)
          .setDeletable(false)
          .setWritable(false)
          .build();
      newRoot.addChild(child);
      remoteObjectTree.setRoot(newRoot);

      Element messageElement = Elements.createSpanElement(css.consoleObject());
      messageElement.appendChild(remoteObjectTree.getView().getElement());
      return messageElement;
    }

    private Element formatLocationLink(String url, int lineNumber, int columnNumber) {
      // TODO: Do real URL parsing to get the path's last component.
      String locationName = url;
      int pos = locationName.lastIndexOf('/');
      if (pos != -1) {
        locationName = locationName.substring(pos + 1);
        if (StringUtils.isNullOrEmpty(locationName)) {
          locationName = "/";
        }
      }

      AnchorElement anchor = Elements.createAnchorElement(css.messageLink());
      anchor.setHref(url);
      anchor.setTarget("_blank");
      anchor.setTitle(url);
      anchor.setTextContent(locationName + ":" + lineNumber);
      return anchor;
    }

    private Element formatStackTrace(JsonArray<StackTraceItem> stackTrace) {
      Element stackTraceElement = Elements.createDivElement(css.consoleStackTrace());
      for (int i = 0, n = stackTrace.size(); i < n; ++i) {
        StackTraceItem item = stackTrace.get(i);
        Element itemElement = Elements.createDivElement(css.consoleStackTraceItem());
        itemElement.appendChild(
            formatLocationLink(item.getUrl(), item.getLineNumber(), item.getColumnNumber()));
        itemElement.appendChild(Elements.createTextNode(
            StringUtils.ensureNotEmpty(item.getFunctionName(), "(anonymous function)")));
        stackTraceElement.appendChild(itemElement);
      }
      return stackTraceElement;
    }

    private void clearConsoleMessages() {
      for (int i = 0, n = remoteObjectTrees.size(); i < n; ++i) {
        remoteObjectTrees.get(i).teardown();
      }
      remoteObjectTrees.clear();
      consoleMessages.setInnerHTML("");
    }
  }

  /**
   * The view events.
   */
  private interface ViewEvents {
    // TODO: Add the button into the UI.
    void onClearConsoleButtonClick();
    void onLocationLinkClick(String url, int lineNumber);
  }

  static ConsoleView create(View view, DebuggerState debuggerState) {
    return new ConsoleView(view, debuggerState);
  }

  private final DebuggerState debuggerState;
  private Listener listener;

  private final DebuggerState.ConsoleListener consoleListener =
      new DebuggerState.ConsoleListener() {
        @Override
        public void onConsoleMessage(ConsoleMessage message) {
          getView().appendConsoleMessage(message, debuggerState);
        }

        @Override
        public void onConsoleMessageRepeatCountUpdated(ConsoleMessage message, int repeatCount) {
          getView().updateLastConsoleMessageCount(repeatCount);
        }

        @Override
        public void onConsoleMessagesCleared() {
          getView().clearConsoleMessages();
        }
      };

  private ConsoleView(View view, DebuggerState debuggerState) {
    super(view);

    this.debuggerState = debuggerState;
    debuggerState.getConsoleListenerRegistrar().add(consoleListener);

    view.setDelegate(new ViewEvents() {
      @Override
      public void onClearConsoleButtonClick() {
        if (ConsoleView.this.debuggerState.isActive()) {
          // TODO: Implement it in the debugger API.
        } else {
          getView().clearConsoleMessages();
        }
      }

      @Override
      public void onLocationLinkClick(String url, int lineNumber) {
        if (listener != null) {
          listener.onLocationLinkClick(url, lineNumber);
        }
      }
    });
  }

  void setListener(Listener listener) {
    this.listener = listener;
  }

  void show() {
    // Do nothing.
  }

  void hide() {
    getView().clearConsoleMessages();
  }
}
