// 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.common.CanRunApplication;

import com.google.collide.client.bootstrap.BootstrapSession;
import com.google.collide.client.code.FileSelectedPlace;
import com.google.collide.client.code.RightSidebarExpansionEvent;
import com.google.collide.client.code.popup.EditorPopupController;
import com.google.collide.client.communication.ResourceUriUtils;
import com.google.collide.client.document.DocumentManager;
import com.google.collide.client.documentparser.DocumentParser;
import com.google.collide.client.editor.Editor;
import com.google.collide.client.editor.gutter.Gutter;
import com.google.collide.client.history.Place;
import com.google.collide.client.util.PathUtil;
import com.google.collide.client.workspace.PopupBlockedInstructionalPopup;
import com.google.collide.client.workspace.RunApplicationEvent;
import com.google.collide.json.shared.JsonArray;
import com.google.collide.shared.document.Document;
import com.google.collide.shared.document.anchor.Anchor;
import com.google.collide.shared.util.ListenerRegistrar;
import com.google.common.base.Preconditions;

import elemental.client.Browser;
import elemental.dom.Element;
import elemental.html.Window;

/**
 * A controller for the debugging model state.
 */
public class DebuggingModelController <R extends 
  DebuggingModelRenderer.Resources &
  PopupBlockedInstructionalPopup.Resources &
  EvaluationPopupController.Resources & 
  DebuggingSidebar.Resources> implements CanRunApplication {

  public static <R extends 
    DebuggingModelRenderer.Resources & 
    PopupBlockedInstructionalPopup.Resources & 
    EvaluationPopupController.Resources &
    DebuggingSidebar.Resources> 
    DebuggingModelController<R> create(Place currentPlace, R resources,
      DebuggingModel debuggingModel, Editor editor, EditorPopupController editorPopupController,
      DocumentManager documentManager) {
    DebuggingModelController<R> dmc = new DebuggingModelController<R>(currentPlace, resources,
        debuggingModel, editor, editorPopupController, documentManager);
    dmc.populateDebuggingSidebar();

    // Register the DebuggingModelController as a handler for clicks on the
    // Header's run button.
    currentPlace.registerSimpleEventHandler(RunApplicationEvent.TYPE, dmc.runApplicationHandler);
    return dmc;
  }

  /**
   * Flag indicating that "popup blocked" alert was shown once to user, so we
   * should not annoy user with it again.
   *
   * <p>This flag is used by and changed by {@link #createOrOpenPopup}.
   */
  private static boolean doNotShowPopupBlockedInstruction;

  /**
   * Creates or reuses launchpad window.
   *
   * <p>This method should be called from user initiated event handler.
   *
   * <p>If popup window already exists, it is cleared. Launchpad is filled
   * with disclaimer that informs user that deployment is in progress.
   *
   * <p>Actually, popup is never blocked, because it is initiated by user.
   * But in case it is not truth, we show alert to user that instructs how to
   * enable popups.
   */
  public static Window createOrOpenPopup(PopupBlockedInstructionalPopup.Resources res) {
    Window popup =
        Browser.getWindow().open("", BootstrapSession.getBootstrapSession().getActiveClientId());
    if (popup == null) {
      if (!doNotShowPopupBlockedInstruction) {
        doNotShowPopupBlockedInstruction = true;
        PopupBlockedInstructionalPopup.create(res).show();
      }
    }
    return popup;
  }

  /**
   * Handler for clicks on the run button on the workspace header.
   */
  private final RunApplicationEvent.Handler runApplicationHandler =
      new RunApplicationEvent.Handler() {
        @Override
        public void onRunButtonClicked(RunApplicationEvent evt) {
          runApplication(evt.getUrl());
        }
      };

  private final DebuggingModel.DebuggingModelChangeListener debuggingModelChangeListener =
      new DebuggingModel.DebuggingModelChangeListener() {
        @Override
        public void onBreakpointAdded(Breakpoint newBreakpoint) {
          if (shouldProcessBreakpoint(newBreakpoint) && !breakpoints.contains(newBreakpoint)) {
            anchorBreakpointAndUpdateSidebar(newBreakpoint);
            debuggingModelRenderer.renderBreakpointOnGutter(
                newBreakpoint.getLineNumber(), newBreakpoint.isActive());
          }
          debuggerState.setBreakpoint(newBreakpoint);
          debuggingSidebar.addBreakpoint(newBreakpoint);
        }

        @Override
        public void onBreakpointRemoved(Breakpoint oldBreakpoint) {
          if (shouldProcessBreakpoint(oldBreakpoint) && breakpoints.contains(oldBreakpoint)) {
            breakpoints.removeBreakpoint(oldBreakpoint);
            debuggingModelRenderer.removeBreakpointOnGutter(oldBreakpoint.getLineNumber());
          }
          debuggerState.removeBreakpoint(oldBreakpoint);
          debuggingSidebar.removeBreakpoint(oldBreakpoint);
        }

        @Override
        public void onBreakpointReplaced(Breakpoint oldBreakpoint, Breakpoint newBreakpoint) {
          /*
           * If a breakpoint is not in the document being displayed, we do not
           * know the code line that it was attached to, thus we save the old
           * breakpoint's line, if any.
           */
          String breakpointLine = debuggingSidebar.getBreakpointLineText(oldBreakpoint);

          onBreakpointRemoved(oldBreakpoint);
          onBreakpointAdded(newBreakpoint);

          if (!shouldProcessBreakpoint(newBreakpoint)) {
            debuggingSidebar.updateBreakpoint(newBreakpoint, breakpointLine);
          }
        }

        @Override
        public void onPauseOnExceptionsModeUpdated(DebuggingModel.PauseOnExceptionsMode oldMode,
            DebuggingModel.PauseOnExceptionsMode newMode) {
          // TODO: Implement this in the UI.
          // TODO: Update DebuggerState.
        }

        @Override
        public void onBreakpointsEnabledUpdated(boolean newValue) {
          debuggerState.setBreakpointsEnabled(newValue);
          debuggingSidebar.setAllBreakpointsActive(newValue);
        }
      };

  private final Gutter.ClickListener leftGutterClickListener = new Gutter.ClickListener() {
    @Override
    public void onClick(int y) {
      int lineNumber = editor.getBuffer().convertYToLineNumber(y, true);
      for (int i = 0; i < breakpoints.size(); ++i) {
        Breakpoint breakpoint = breakpoints.get(i);
        if (breakpoint.getLineNumber() == lineNumber) {
          debuggingModel.removeBreakpoint(breakpoint);
          return;
        }
      }

      if (!isCurrentDocumentEligibleForDebugging()) {
        return;
      }

      Breakpoint breakpoint = new Breakpoint.Builder(path, lineNumber).build();
      debuggingModel.addBreakpoint(breakpoint);

      // Show the sidebar if the very first breakpoint has just been set.
      maybeShowSidebar();
    }
  };

  private boolean isCurrentDocumentEligibleForDebugging() {
    Preconditions.checkNotNull(path);
    // TODO: Be more smart here. Also what about source mappings?
    String baseName = path.getBaseName().toLowerCase();
    return baseName.endsWith(".js") || baseName.endsWith(".htm") || baseName.endsWith(".html");
  }

  /**
   * Handler that receives events from the remote debugger about changes of it's
   * state (e.g. a {@code running} debugger changed to {@code paused}, etc.).
   */
  private final DebuggerState.DebuggerStateListener debuggerStateListener =
      new DebuggerState.DebuggerStateListener() {
        @Override
        public void onDebuggerStateChange() {
          if (debuggerState.isPaused()) {
            showSidebar();
          }

          debuggingModelRenderer.renderDebuggerState();
          handleOnCallFrameSelect(0);
        }
      };

  /**
   * Handler that receives user commands, such as clicks on the debugger control
   * buttons in the sidebar.
   */
  private final DebuggingSidebar.DebuggerCommandListener userCommandListener =
      new DebuggingSidebar.DebuggerCommandListener() {
        @Override
        public void onPause() {
          debuggerState.pause();
        }

        @Override
        public void onResume() {
          debuggerState.resume();
        }

        @Override
        public void onStepOver() {
          debuggerState.stepOver();
        }

        @Override
        public void onStepInto() {
          debuggerState.stepInto();
        }

        @Override
        public void onStepOut() {
          debuggerState.stepOut();
        }

        @Override
        public void onCallFrameSelect(int depth) {
          handleOnCallFrameSelect(depth);
        }

        @Override
        public void onBreakpointIconClick(Breakpoint breakpoint) {
          Breakpoint newBreakpoint = new Breakpoint.Builder(breakpoint)
              .setActive(!breakpoint.isActive())
              .build();
          debuggingModel.updateBreakpoint(breakpoint, newBreakpoint);
        }

        @Override
        public void onBreakpointLineClick(Breakpoint breakpoint) {
          maybeNavigateToDocument(breakpoint.getPath(), breakpoint.getLineNumber());
        }

        @Override
        public void onActivateBreakpoints() {
          debuggingModel.setBreakpointsEnabled(true);
        }

        @Override
        public void onDeactivateBreakpoints() {
          debuggingModel.setBreakpointsEnabled(false);
        }

        @Override
        public void onLocationLinkClick(String url, int lineNumber) {
          SourceMapping sourceMapping = debuggerState.getSourceMapping();
          if (sourceMapping == null) {
            // Ignore. Maybe the debugger has just been shutdown.
            return;
          }

          PathUtil path = sourceMapping.getLocalSourcePath(url);
          if (path != null) {
            maybeNavigateToDocument(path, lineNumber);
          }
        }
      };

  /**
   * Handler that receives notifications when a breakpoint description changes.
   */
  private final AnchoredBreakpoints.BreakpointDescriptionListener breakpointDescriptionListener =
      new AnchoredBreakpoints.BreakpointDescriptionListener() {
        @Override
        public void onBreakpointDescriptionChange(Breakpoint breakpoint, String newText) {
          debuggingSidebar.updateBreakpoint(breakpoint, newText);
        }
      };

  private final PopupBlockedInstructionalPopup.Resources popupResources;
  private final Editor editor;
  private final DebuggingModel debuggingModel;
  private final ListenerRegistrar.Remover leftGutterClickListenerRemover;
  private final DebuggerState debuggerState;
  private final DebuggingSidebar debuggingSidebar;
  private final DebuggingModelRenderer debuggingModelRenderer;
  private final Place currentPlace;
  private final CssLiveEditController cssLiveEditController;
  private final EvaluationPopupController evaluationPopupController;

  private PathUtil path;
  private AnchoredBreakpoints breakpoints;

  /**
   * Indicator that sidebar has been discovered by user.
   */
  private boolean sidebarDiscovered;

  private DebuggingModelController(Place currentPlace, R resources,
      DebuggingModel debuggingModel, Editor editor, EditorPopupController editorPopupController,
      DocumentManager documentManager) {
    this.popupResources = resources;
    this.editor = editor;
    this.currentPlace = currentPlace;
    this.debuggingModel = debuggingModel;
    this.leftGutterClickListenerRemover =
        editor.getLeftGutter().getClickListenerRegistrar().add(leftGutterClickListener);

    // Every time we enter workspace, we get a new debugging session id.
    String sessionId = BootstrapSession.getBootstrapSession().getActiveClientId() + ":"
        + System.currentTimeMillis();
    this.debuggerState = DebuggerState.create(sessionId);

    this.debuggingSidebar = DebuggingSidebar.create(resources, debuggerState);
    this.debuggingModelRenderer =
        DebuggingModelRenderer.create(resources, editor, debuggingSidebar, debuggerState);
    this.cssLiveEditController = new CssLiveEditController(debuggerState, documentManager);
    this.evaluationPopupController = EvaluationPopupController.create(
        resources, editor, editorPopupController, debuggerState);

    this.debuggingModel.addModelChangeListener(debuggingModelChangeListener);
    this.debuggerState.getDebuggerStateListenerRegistrar().add(debuggerStateListener);
    this.debuggingSidebar.getDebuggerCommandListenerRegistrar().add(userCommandListener);
  }

  public void setDocument(Document document, PathUtil path, DocumentParser parser) {
    if (breakpoints != null) {
      breakpoints.teardown();
      debuggingModelRenderer.handleDocumentChanged();
    }
    this.path = path;
    breakpoints = new AnchoredBreakpoints(debuggingModel, document);
    anchorBreakpoints();
    maybeAnchorExecutionLine();

    evaluationPopupController.setDocument(document, path, parser);
    breakpoints.setBreakpointDescriptionListener(breakpointDescriptionListener);
  }

  public Element getDebuggingSidebarElement() {
    return debuggingSidebar.getView().getElement();
  }

  /**
   * @see #runApplication(String)
   */
  public boolean runApplication(PathUtil applicationPath) {
    String baseUri = ResourceUriUtils.getAbsoluteResourceBaseUri();
    SourceMapping sourceMapping = StaticSourceMapping.create(baseUri);
    return runApplication(sourceMapping, sourceMapping.getRemoteSourceUri(applicationPath));
  }

  /**
   * Runs a given resource via debugger API if it is available, or just opens
   * the resource in a new window.
   *
   * @param absoluteResourceUri absolute resource URI
   * @return {@code true} if the application was started successfully via
   *         debugger API, {@code false} if it was opened in a new window
   */
  public boolean runApplication(String absoluteResourceUri) {
    final String baseUri;

    // Check if the URI points to a local path.
    String workspaceBaseUri = ResourceUriUtils.getAbsoluteResourceBaseUri();
    if (absoluteResourceUri.startsWith(workspaceBaseUri + "/")) {
      baseUri = workspaceBaseUri;
    } else {
      baseUri = ResourceUriUtils.extractBaseUri(absoluteResourceUri);
    }

    SourceMapping sourceMapping = StaticSourceMapping.create(baseUri);
    return runApplication(sourceMapping, absoluteResourceUri);
  }

  private boolean runApplication(SourceMapping sourceMapping, String absoluteResourceUri) {
    if (debuggerState.isDebuggerAvailable()) {
      debuggerState.runDebugger(sourceMapping, absoluteResourceUri);

      JsonArray<Breakpoint> allBreakpoints = debuggingModel.getBreakpoints();
      for (int i = 0; i < allBreakpoints.size(); ++i) {
        debuggerState.setBreakpoint(allBreakpoints.get(i));
      }

      debuggerState.setBreakpointsEnabled(debuggingModel.isBreakpointsEnabled());
      return true;
    } else {
      Window popup = createOrOpenPopup(popupResources);
      if (popup != null) {
        popup.getLocation().assign(absoluteResourceUri);
        // Show the sidebar once to promote the Debugger Extension.
        maybeShowSidebar();
      }
      return false;
    }
  }

  private void anchorBreakpoints() {
    JsonArray<Breakpoint> allBreakpoints = debuggingModel.getBreakpoints();
    for (int i = 0; i < allBreakpoints.size(); ++i) {
      Breakpoint breakpoint = allBreakpoints.get(i);
      if (path.equals(breakpoint.getPath())) {
        anchorBreakpointAndUpdateSidebar(breakpoint);
        debuggingModelRenderer.renderBreakpointOnGutter(
            breakpoint.getLineNumber(), breakpoint.isActive());
      }
    }
  }

  private void populateDebuggingSidebar() {
    JsonArray<Breakpoint> allBreakpoints = debuggingModel.getBreakpoints();
    for (int i = 0; i < allBreakpoints.size(); ++i) {
      debuggingSidebar.addBreakpoint(allBreakpoints.get(i));
    }
  }

  private void anchorBreakpointAndUpdateSidebar(Breakpoint breakpoint) {
    Anchor anchor = breakpoints.anchorBreakpoint(breakpoint);
    debuggingSidebar.updateBreakpoint(breakpoint, anchor.getLine().getText());
  }

  private void maybeAnchorExecutionLine() {
    PathUtil callFramePath = debuggerState.getActiveCallFramePath();
    if (callFramePath != null && callFramePath.equals(path)) {
      int lineNumber = debuggerState.getActiveCallFrameExecutionLineNumber();
      if (lineNumber >= 0) {
        debuggingModelRenderer.renderExecutionLine(lineNumber);
      }
    }
  }

  private void showSidebar() {
    sidebarDiscovered = true;
    currentPlace.fireEvent(new RightSidebarExpansionEvent(true));
  }

  /**
   * Shows sidebar in case user hasn't discovered yet, and at least one
   * breakpoint is set.
   */
  private void maybeShowSidebar() {
    if (!sidebarDiscovered && debuggingModel.getBreakpointCount() != 0) {
      showSidebar();
    }
  }

  private boolean shouldProcessBreakpoint(Breakpoint breakpoint) {
    return path != null && path.equals(breakpoint.getPath());
  }

  private void handleOnCallFrameSelect(int callFrameDepth) {
    debuggerState.setActiveCallFrameIndex(callFrameDepth);
    debuggingModelRenderer.renderDebuggerCallFrame();

    PathUtil callFramePath = debuggerState.getActiveCallFramePath();
    if (callFramePath == null) {
      // Not paused, remove the execution line (if any).
      debuggingModelRenderer.removeExecutionLine();
      return;
    }

    maybeAnchorExecutionLine();

    int lineNumber = debuggerState.getActiveCallFrameExecutionLineNumber();
    maybeNavigateToDocument(callFramePath, lineNumber);
  }

  private void maybeNavigateToDocument(PathUtil documentPath, int lineNumber) {
    if (documentPath.equals(this.path)) {
      editor.getFocusManager().focus();
      editor.scrollTo(lineNumber, 0);
    } else {
      currentPlace.fireChildPlaceNavigation(
          FileSelectedPlace.PLACE.createNavigationEvent(documentPath, lineNumber));
    }
  }

  public void cleanup() {
    debuggerState.shutdown();
    leftGutterClickListenerRemover.remove();
  }
}
