// 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.editor.renderer;

import collide.client.util.Elements;

import com.google.collide.client.document.linedimensions.LineDimensionsUtils;
import com.google.collide.client.editor.Buffer;
import com.google.collide.client.util.logging.Log;
import com.google.collide.json.shared.JsonArray;
import com.google.collide.shared.document.Line;
import com.google.collide.shared.util.JsonCollections;
import com.google.collide.shared.util.SortedList;
import com.google.collide.shared.util.StringUtils;
import com.google.common.base.Preconditions;

import elemental.css.CSSStyleDeclaration;
import elemental.dom.Element;
import elemental.html.SpanElement;

/**
 * A class to maintain the list of {@link LineRenderer LineRenderers} and render
 * a line by delegating to each of the renderers.
 */
class LineRendererController {

  /*
   * TODO: consider recycling these if GC performance during
   * rendering is an issue
   */
  private static class LineRendererTarget implements LineRenderer.Target {

    private static class Comparator implements SortedList.Comparator<LineRendererTarget> {
      @Override
      public int compare(LineRendererTarget a, LineRendererTarget b) {
        return a.remainingCount - b.remainingCount;
      }
    }

    /** The line renderer for which this is the target */
    private final LineRenderer lineRenderer;

    /**
     * The remaining number of characters that should receive {@link #styleName}
     * . Once this is 0, the {@link #lineRenderer} will be asked to render its
     * next chunk
     */
    private int remainingCount;

    /** The style to be applied to the {@link #remainingCount} */
    private String styleName;

    public LineRendererTarget(LineRenderer lineRenderer) {
      this.lineRenderer = lineRenderer;
    }

    @Override
    public void render(int characterCount, String styleName) {
      remainingCount = characterCount;
      this.styleName = styleName;
    }
  }

  /**
   * A sorted list storing targets for the line renderers that are participating
   * in rendering the current line
   */
  private final SortedList<LineRendererTarget> currentLineRendererTargets;

  /**
   * A list of all of the line renderers that are registered on the editor (Note
   * that some may not be participating in the current line)
   */
  private final JsonArray<LineRenderer> lineRenderers;
  private final Buffer buffer;

  LineRendererController(Buffer buffer) {
    this.buffer = buffer;
    currentLineRendererTargets = new SortedList<LineRendererController.LineRendererTarget>(
        new LineRendererTarget.Comparator());
    lineRenderers = JsonCollections.createArray();
  }

  void addLineRenderer(LineRenderer lineRenderer) {
    if (!lineRenderers.contains(lineRenderer)) {
      /*
       * Prevent line renderer from appearing twice in the list if it is already
       * added.
       */
      lineRenderers.add(lineRenderer);
    }
  }

  void removeLineRenderer(LineRenderer lineRenderer) {
    lineRenderers.remove(lineRenderer);
  }

  void renderLine(Line line, int lineNumber, Element targetElement, boolean isTargetElementEmpty) {

    currentLineRendererTargets.clear();

    if (!resetLineRenderers(line, lineNumber)) {
      // No line renderers are participating, so exit early.
      setTextContentSafely(targetElement, line.getText());
      return;
    }

    if (!isTargetElementEmpty) {
      targetElement.setInnerHTML("");
    }

    Element contentElement = Elements.createSpanElement();
    contentElement.getStyle().setDisplay(CSSStyleDeclaration.Display.INLINE_BLOCK);
    for (int indexInLine = 0, lineSize = line.getText().length();
        indexInLine < lineSize && ensureAllRenderersHaveARenderedNextChunk();) {

      int chunkSize = currentLineRendererTargets.get(0).remainingCount;
      if (chunkSize == 0) {
        // Bad news, revert to naive rendering and log
        setTextContentSafely(targetElement, line.getText());
        Log.error(getClass(), "Line renderers do not have remaining chunks");
        return;
      }

      renderChunk(line.getText(), indexInLine, chunkSize, contentElement);
      markChunkRendered(chunkSize);

      indexInLine += chunkSize;
    }
    targetElement.appendChild(contentElement);

    if (line.getText().endsWith("\n")) {
      Element lastChunk = (Element) contentElement.getLastChild();
      Preconditions.checkState(lastChunk != null, "This line has no chunks!");
      if (!StringUtils.isNullOrWhitespace(lastChunk.getClassName())) {
        contentElement.getStyle().setProperty("float", "left");
        Element newlineCharacterElement = createLastChunkElement(targetElement);
        // Created on demand only because it is rarely used.
        Element remainingSpaceElement = null;
        for (int i = 0, n = currentLineRendererTargets.size(); i < n; i++) {
          LineRendererTarget target = currentLineRendererTargets.get(i);
          if (target.styleName != null) {
            if (!target.lineRenderer.shouldLastChunkFillToRight()) {
              newlineCharacterElement.addClassName(target.styleName);
            } else {
              if (remainingSpaceElement == null) {
                newlineCharacterElement.getStyle().setProperty("float", "left");
                remainingSpaceElement = createLastChunkElement(targetElement);
                remainingSpaceElement.getStyle().setWidth("100%");
              }
              // Also apply to last chunk element so that there's no gap.
              newlineCharacterElement.addClassName(target.styleName);
              remainingSpaceElement.addClassName(target.styleName);
            }
          }
        }
      }
    }
  }

  private static Element createLastChunkElement(Element parent) {
    // we need to give them a whitespace element so that it can be styled.
    Element whitespaceElement = Elements.createSpanElement();
    whitespaceElement.setTextContent("\u00A0");
    whitespaceElement.getStyle().setDisplay("inline-block");
    parent.appendChild(whitespaceElement);
    return whitespaceElement;
  }

  /**
   * Ensures all renderer targets (that want to render) have rendered each of
   * their next chunks.
   */
  private boolean ensureAllRenderersHaveARenderedNextChunk() {
    while (currentLineRendererTargets.size() > 0
        && currentLineRendererTargets.get(0).remainingCount == 0) {
      LineRendererTarget target = currentLineRendererTargets.get(0);
      try {
        target.lineRenderer.renderNextChunk(target);
      } catch (Throwable t) {
        // Cause naive rendering
        target.remainingCount = 0;
        Log.warn(getClass(), "An exception was thrown from renderNextChunk", t);
      }
      
      if (target.remainingCount > 0) {
        currentLineRendererTargets.repositionItem(0);
      } else {
        // Remove the line renderer because it has broken our contract
        currentLineRendererTargets.remove(0);
        Log.warn(getClass(), "The line renderer " + target.lineRenderer
            + " is lacking a next chunk, removing from rendering");
      }
    }

    return currentLineRendererTargets.size() > 0;
  }

  /**
   * Marks the chunk rendered on all the renderers.
   */
  private void markChunkRendered(int chunkSize) {
    for (int i = 0, n = currentLineRendererTargets.size(); i < n; i++) {
      LineRendererTarget target = currentLineRendererTargets.get(i);
      target.remainingCount -= chunkSize;
    }
  }

  /**
   * Renders the chunk by creating a span with all of the individual line
   * renderer's styles.
   */
  private void renderChunk(String lineText, int lineIndex, int chunkLength, Element targetElement) {
    SpanElement element = Elements.createSpanElement();
    // TODO: file a Chrome bug, place link here
    element.getStyle().setDisplay(CSSStyleDeclaration.Display.INLINE_BLOCK);
    setTextContentSafely(element, lineText.substring(lineIndex, lineIndex + chunkLength));
    applyStyles(element);
    targetElement.appendChild(element);
  }

  private void applyStyles(Element element) {
    for (int i = 0, n = currentLineRendererTargets.size(); i < n; i++) {
      LineRendererTarget target = currentLineRendererTargets.get(i);
      if (target.styleName != null) {
        element.addClassName(target.styleName);
      }
    }
  }

  /**
   * Resets the line renderers, preparing for a new line to be rendered.
   *
   * This method fills the {@link #currentLineRendererTargets} with targets for
   * line renderers that will participate in rendering this line.
   *
   * @return true if there is at least one line renderer participating for the
   *         given @{link Line} line.
   */
  private boolean resetLineRenderers(Line line, int lineNumber) {
    boolean hasAtLeastOneParticipatingLineRenderer = false;
    for (int i = 0; i < lineRenderers.size(); i++) {
      LineRenderer lineRenderer = lineRenderers.get(i);
      boolean isParticipating = lineRenderer.resetToBeginningOfLine(line, lineNumber);
      if (isParticipating) {
        currentLineRendererTargets.add(new LineRendererTarget(lineRenderer));
        hasAtLeastOneParticipatingLineRenderer = true;
      }
    }

    return hasAtLeastOneParticipatingLineRenderer;
  }

  private void setTextContentSafely(Element element, String text) {
    String cleansedText = text.replaceAll("\t", LineDimensionsUtils.getTabAsSpaces());
    element.setTextContent(cleansedText);
  }
}
