/*
 * Copyright 2013 Google Inc.
 *
 * 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.template.soy.parsepasses.contextautoesc;

import com.google.common.annotations.VisibleForTesting;
import com.google.common.base.Preconditions;
import com.google.common.base.Predicate;
import com.google.common.base.Predicates;
import com.google.common.collect.ImmutableList;
import com.google.common.collect.Lists;
import com.google.template.soy.soytree.RawTextNode;

import java.util.Collections;
import java.util.List;
import javax.annotation.Nullable;

/**
 * A raw text node divided into a slice for each context found by the inference engine so that later
 * parse passes can take action based on text and attribute boundaries.
 *
 */
public final class SlicedRawTextNode {


  /**
   * A substring of raw text that is exposed to parse passes.
   * <p>
   * This slice is not the entire substring with the same context.  Such a thing cannot be
   * statically determined since in a portion of a template that stays in the same context like
   * <pre>
   *   foo {if $cond}bar{else}baz{/if} boo
   * </pre>
   * there might be two possible strings with the same context: {@code "foo bar boo"} and
   * {@code "foo baz boo"}.
   * <p>
   */
  public static final class RawTextSlice {

    /** The context of the slice. */
    public final Context context;

    /** The text node containing the slice. */
    public final SlicedRawTextNode slicedRawTextNode;

    /** The start offset (inclusive) into the text node's text. */
    private int startOffset;

    /** The end offset (exclusive) into the text node's text. */
    private int endOffset;

    RawTextSlice(
        Context context, SlicedRawTextNode slicedRawTextNode, int startOffset, int endOffset) {
      this.context = context;
      this.slicedRawTextNode = slicedRawTextNode;
      this.startOffset = startOffset;
      this.endOffset = endOffset;
    }

    /** The start offset (inclusive) into the text node's text. */
    public int getStartOffset() {
      return startOffset;
    }

    /** The length of the slice in {@code char}s. */
    public int getLength() {
      return endOffset - startOffset;
    }

    public SlicedRawTextNode getSlicedRawTextNode() {
      return slicedRawTextNode;
    }

    /**
     * Splits this slice in two at the given offset and returns the slice after the split.
     * @param offset into the slice.
     */
    private RawTextSlice split(int offset) {
      int indexInParent = slicedRawTextNode.slices.indexOf(this);
      if (indexInParent < 0) {
        throw new AssertionError("slice is not in its parent");
      }
      Preconditions.checkElementIndex(offset, getLength(), "slice offset");
      RawTextSlice secondSlice = slicedRawTextNode.insertSlice(indexInParent + 1, context, 0);
      int wholeTextOffset = offset + startOffset;
      secondSlice.startOffset = wholeTextOffset;
      this.endOffset = wholeTextOffset;
      return secondSlice;
    }

    /**
     * Mutates the parse tree by replacing the sliced text node with a text node that
     * includes the given text at the given point within this slice.
     * @param text A string in context context.
     * @param offset An offset between 0 (inclusive) and {@link #getLength()} (exclusive).
     * @param context the context of text.
     * @throws SoyAutoescapeException if inserting text would violate context assumptions made by
     *     the contextual autoescaper.
     */
    public void insertText(int offset, String text) throws SoyAutoescapeException {
      int indexInParent = slicedRawTextNode.slices.indexOf(this);
      if (indexInParent < 0) {
        throw new AssertionError("slice is not in its parent");
      }
      Preconditions.checkElementIndex(offset, getLength(), "slice offset");

      // Figure out at which character offset to insert text.
      int insertionIndex = -1;
      int insertionOffset = -1;

      // Split or recurse as necessary so that we are called at the boundary of a slice.
      if (offset == 0) {
        insertionIndex = indexInParent;
        insertionOffset = startOffset;
      } else if (offset == getLength()) {
        insertionIndex = indexInParent;
        insertionOffset = endOffset;
      } else {
        split(offset).insertText(0, text);
        return;
      }

      // Compute the new raw text and create a node to hold it.
      // We re-use the node ID since we're going to remove the old node and discard it.
      RawTextNode rawTextNode = slicedRawTextNode.getRawTextNode();
      String originalText = rawTextNode.getRawText();
      String replacementText =
        originalText.substring(0, insertionOffset) + text + originalText.substring(insertionOffset);
      RawTextNode replacementNode = new RawTextNode(
          rawTextNode.getId(), replacementText, rawTextNode.getSourceLocation());

      // Rerun the context update algo so that we can figure out the context of the inserted slices
      // and ensure that the inserted text does not invalidate any of the security assumptions made
      // by the auto-escaper.
      Context startContext = slicedRawTextNode.startContext;
      Context expectedEndContext = slicedRawTextNode.endContext;
      SlicedRawTextNode retyped = RawTextContextUpdater.processRawText(
          replacementNode, startContext);
      Context actualEndContext = retyped.getEndContext();

      if (!expectedEndContext.equals(actualEndContext)) {
        // Inserting the text would invalidate typing assumptions made earlier.
        throw SoyAutoescapeException.createWithNode(
            "Inserting `" + text + "` would cause text node to end in context " + actualEndContext
            + " instead of " + expectedEndContext,
            rawTextNode);
      }

      // Now that we know that it's valid to insert the text at that location, replace the text node
      // and insert slices for each of the slices in builder corresponding to characters in text.
      slicedRawTextNode.replaceNode(replacementNode);
      int insertionEndOffset = insertionOffset + text.length();
      for (RawTextSlice slice : retyped.slices) {
        if (slice.endOffset <= insertionOffset) {
          continue;
        }
        if (slice.startOffset >= insertionEndOffset) {
          break;
        }
        int length = Math.min(insertionEndOffset, slice.endOffset)
            - Math.max(insertionOffset, slice.startOffset);
        slicedRawTextNode.insertSlice(insertionIndex, slice.context, length);
        // Increment the insertion index to point past the slice just inserted so that
        // we're ready for the next one.
        ++insertionIndex;
      }
    }

    /** The raw text of the slice. */
    public String getRawText() {
      return slicedRawTextNode.rawTextNode.getRawText().substring(startOffset, endOffset);
    }

    /** Adjusts the start and end offsets right by the given amount. */
    void shiftOffsets(int delta) {
      startOffset += delta;
      endOffset += delta;
    }

    /** For debugging. */
    @Override
    public String toString() {
      String rawText = getRawText();
      int id = slicedRawTextNode.rawTextNode.getId();
      // "<rawText>"@<textNodeId> with \ and " escaped.
      return "\"" + rawText.replaceAll("\"|\\\\", "\\\\$0") + "\"#" + id;
    }
  }


  /** The backing raw text node. */
  private RawTextNode rawTextNode;
  /** The context in which the text node starts. */
  private final Context startContext;
  /** The context in which the text node ends. */
  private Context endContext;
  /** A collection of all the slices of a particular raw text node in order. */
  private final List<RawTextSlice> slices = Lists.newArrayList();

  public SlicedRawTextNode(RawTextNode rawTextNode, Context startContext) {
    this.rawTextNode = rawTextNode;
    this.startContext = startContext;
  }

  public RawTextNode getRawTextNode() {
    return rawTextNode;
  }

  public List<RawTextSlice> getSlices() {
    return Collections.unmodifiableList(slices);
  }

  /** The context in which the text node ends. */
  public Context getEndContext() {
    return endContext;
  }

  void setEndContext(Context endContext) {
    this.endContext = endContext;
  }

  /**
   * Called by the builder to add slices as their context becomes known.
   * @param startOffset an offset (inclusive) into the rawTextNode's string content.
   * @param endOffset an offset (exclusive) into the rawTextNode's string content.
   * @param context the context for the slice.
   */
  void addSlice(int startOffset, int endOffset, Context context) {
    int lastSliceIndex = slices.size() - 1;
    // Merge adjacent tokens that don't change context.
    if (lastSliceIndex >= 0) {
      RawTextSlice last = slices.get(lastSliceIndex);
      if (last.endOffset == startOffset && context.equals(last.context)) {
        slices.remove(lastSliceIndex);
        startOffset = last.startOffset;
      }
    }
    slices.add(new RawTextSlice(context, this, startOffset, endOffset));
  }

  /** Replaces the backing node in the parse tree and internally. */
  void replaceNode(RawTextNode replacement) {
    rawTextNode.getParent().replaceChild(rawTextNode, replacement);
    this.rawTextNode = replacement;
  }

  /**
   * Inserts a slice, updating the offsets of any following slices and returns the newly created
   * slice.
   */
  RawTextSlice insertSlice(int index, Context context, int length) {
    if (length < 0) {
      throw new IllegalArgumentException("length " + length + " < 0");
    }
    int startOffset = index == 0 ? 0 : slices.get(index - 1).endOffset;
    for (RawTextSlice follower : slices.subList(index, slices.size())) {
      follower.shiftOffsets(length);
    }
    RawTextSlice slice = new RawTextSlice(context, this, startOffset, startOffset + length);
    slices.add(index, slice);
    return slice;
  }

  @VisibleForTesting
  void mergeAdjacentSlicesWithSameContext() {
    // Rewrite list from left to right merging adjacent slices with the same context.
    int nMerged = 0;
    for (int i = 0, n = slices.size(), next; i < n; i = next, ++nMerged) {
      next = i + 1;
      RawTextSlice slice = slices.get(i);
      // Walk next forward until we see a different context.
      while (next < n && slice.context.equals(slices.get(next).context)) {
        ++next;
      }
      // Modify slices in place to have exactly one slice corresponding to [i, next).
      RawTextSlice merged;
      if (next - i == 1) {
        // If there haven't been modifications since the last merge, don't orphan slices.
        merged = slice;
      } else {
        merged = new RawTextSlice(
            slice.context, this, slice.startOffset, slices.get(next - 1).endOffset);
      }
      slices.set(nMerged, merged);
    }
    // Truncate.
    slices.subList(nMerged, slices.size()).clear();
  }

  /**
   * The slices that occur in the context described by the given predicates.
   *
   * <p>
   * The order is deterministic but does not necessarily bear any relationship to the order in which
   * slices can appear in the template's output because it is dependent on the ordering of
   * individual templates in the parsed input.
   *
   * @param slicedRawTextNodes The sliced raw text nodes to search.
   * @param prevContextPredicate Applied to the context before the slice being tested.
   * @param sliceContextPredicate Applied to the context of the slice being tested.
   * @param nextContextPredicate Applied to the context after the slice being tested.
   * @return a list of slices such that input predicates are all true when applied to the contexts
   *   at and around that slice.
   */
  public static List<RawTextSlice> find(
      Iterable<? extends SlicedRawTextNode> slicedTextNodes,
      @Nullable Predicate<? super Context> prevContextPredicate,
      @Nullable Predicate<? super Context> sliceContextPredicate,
      @Nullable Predicate<? super Context> nextContextPredicate) {
    if (prevContextPredicate == null) {
      prevContextPredicate = Predicates.<Context>alwaysTrue();
    }
    if (sliceContextPredicate == null) {
      sliceContextPredicate = Predicates.<Context>alwaysTrue();
    }
    if (nextContextPredicate == null) {
      nextContextPredicate = Predicates.<Context>alwaysTrue();
    }

    ImmutableList.Builder<RawTextSlice> matches = ImmutableList.builder();
    for (SlicedRawTextNode slicedTextNode : slicedTextNodes) {
      // insertText can leave adjacent slices with the same context.
      // Merge slices so that each element in find()'s result list stands alone.
      // This could cause problems with concurrent iteration over two find lists, but the mutators
      // check that a slice is part of its parent so we will fail fast.
      slicedTextNode.mergeAdjacentSlicesWithSameContext();

      Context prevContext = slicedTextNode.startContext;
      List<RawTextSlice> slices = slicedTextNode.slices;
      for (int i = 0, n = slices.size(); i < n; ++i) {
        RawTextSlice current = slices.get(i);
        Context nextContext;
        if (i + 1 < n) {
          nextContext = slices.get(i + 1).context;
        } else {
          nextContext = slicedTextNode.endContext;
        }
        // Apply the predicates.
        if (prevContextPredicate.apply(prevContext)
            && sliceContextPredicate.apply(current.context)
            && nextContextPredicate.apply(nextContext)) {
          matches.add(current);
        }
        prevContext = current.context;
      }
    }
    return matches.build();
  }

}
