/*
 * Copyright 2019 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 io.flutter.editor;

import com.intellij.openapi.editor.Document;
import com.intellij.openapi.editor.RangeMarker;
import com.intellij.openapi.util.TextRange;
import com.intellij.openapi.vfs.VirtualFile;
import org.dartlang.analysis.server.protocol.FlutterOutline;
import org.jetbrains.annotations.Nullable;

class TextRangeTracker {
  private final TextRange rawRange;
  private RangeMarker marker;
  private String endingWord;

  TextRangeTracker(int offset, int endOffset) {
    rawRange = new TextRange(offset, endOffset);
  }

  void track(Document document) {
    if (marker != null) {
      assert (document == marker.getDocument());
      return;
    }
    // Create a range marker that goes from the start of the indent for the line
    // to the column of the actual entity.
    final int docLength = document.getTextLength();
    final int startOffset = Math.min(rawRange.getStartOffset(), docLength);
    final int endOffset = Math.min(rawRange.getEndOffset(), docLength);

    endingWord = getCurrentWord(document, endOffset - 1);
    marker = document.createRangeMarker(startOffset, endOffset);
  }

  @Nullable
  TextRange getRange() {
    if (marker == null) {
      return rawRange;
    }
    if (!marker.isValid()) {
      return null;
    }
    return new TextRange(marker.getStartOffset(), marker.getEndOffset());
  }

  void dispose() {
    if (marker != null) {
      marker.dispose();
      ;
    }
    marker = null;
  }

  public boolean isTracking() {
    return marker != null && marker.isValid();
  }

  /**
   * Get the next word in the document starting at offset.
   * <p>
   * This helper is used to avoid displaying outline guides where it appears
   * that the word at the start of the outline (e.g. the Widget constructor
   * name) has changed since the guide was created. This catches edge cases
   * where RangeMarkers go off the rails and return strange values after
   * running a code formatter or other tool that generates widespread edits.
   */
  public static String getCurrentWord(Document document, int offset) {
    final int documentLength = document.getTextLength();
    offset = Math.max(0, offset);
    if (offset < 0 || offset >= documentLength) return "";
    final CharSequence chars = document.getCharsSequence();
    // Clamp the max current word length at 20 to avoid slow behavior if the
    // next "word" in the document happened to be incredibly long.
    final int maxWordEnd = Math.min(documentLength, offset + 20);

    int end = offset;
    while (end < maxWordEnd && Character.isAlphabetic(chars.charAt(end))) {
      end++;
    }
    if (offset == end) return "";
    return chars.subSequence(offset, end).toString();
  }

  public boolean isConsistentEndingWord() {
    if (marker == null) {
      return true;
    }
    if (!marker.isValid()) {
      return false;
    }
    return // Verify that the word starting at the end of the marker matches
      // its expected value. This is sometimes not the case if the logic
      // to update marker locations has hit a bad edge case as sometimes
      // happens when there is a large document edit due to running a
      // code formatter.
      endingWord.equals(TextRangeTracker.getCurrentWord(marker.getDocument(), marker.getEndOffset() - 1));
  }
}

/**
 * Class that tracks the location of a FlutterOutline node in a document.
 * <p>
 * Once the track method has been called, edits to the document are reflected
 * by by all locations returned by the outline location.
 */
public class OutlineLocation implements Comparable<OutlineLocation> {
  private final int line;
  private final int column;
  private final int indent;
  private final int offset;
  /**
   * Tracker for the range of lines indent guides for the outline should show.
   */
  private final TextRangeTracker guideTracker;

  /**
   * Tracker for the entire range of text describing this outline location.
   */
  private final TextRangeTracker fullTracker;

  @Nullable
  private String nodeStartingWord;
  private Document document;

  public OutlineLocation(
    FlutterOutline node,
    int line,
    int column,
    int indent,
    VirtualFile file,
    WidgetIndentsHighlightingPass pass
  ) {
    this.line = line;
    this.column = column;
    // These asserts catch cases where the outline is based on inconsistent
    // state with the document.
    // TODO(jacobr): tweak values so if these errors occur they will not
    // cause exceptions to be thrown in release mode.
    assert (indent >= 0);
    assert (column >= 0);
    // It makes no sense for the indent of the line to be greater than the
    // indent of the actual widget.
    assert (column >= indent);
    assert (line >= 0);
    this.indent = indent;
    int nodeOffset = pass.getConvertedOffset(node);
    int endOffset = pass.getConvertedOffset(node.getOffset() + node.getLength());
    fullTracker = new TextRangeTracker(nodeOffset, endOffset);
    final int delta = Math.max(column - indent, 0);
    this.offset = Math.max(nodeOffset - delta, 0);
    guideTracker = new TextRangeTracker(offset, nodeOffset + 1);
  }

  public void dispose() {
    if (guideTracker != null) {
      guideTracker.dispose();
    }
    if (fullTracker != null) {
      fullTracker.dispose();
      ;
    }
  }

  /**
   * This method must be called if the location is set to update to reflect
   * edits to the document.
   * <p>
   * This method must be called at most once and if it is called, dispose must
   * also be called to ensure the range marker is disposed.
   */
  public void track(Document document) {
    this.document = document;

    assert (indent <= column);

    fullTracker.track(document);
    guideTracker.track(document);
  }

  @Override
  public int hashCode() {
    int hashCode = line;
    hashCode = hashCode * 31 + column;
    hashCode = hashCode * 31 + indent;
    hashCode = hashCode * 31 + offset;
    return hashCode;
  }

  @Override
  public boolean equals(Object o) {
    if (this == o) return true;
    if (!(o instanceof OutlineLocation)) return false;
    final OutlineLocation other = (OutlineLocation)o;
    return line == other.line &&
           column == other.column &&
           indent == other.indent &&
           offset == other.offset &&
           getGuideOffset() == other.getGuideOffset();
  }

  /**
   * Offset in the document accurate even if the document has been edited.
   */
  public int getGuideOffset() {
    if (guideTracker.isTracking()) {
      return guideTracker.getRange().getStartOffset();
    }
    return offset;
  }

  // Sometimes markers stop being valid in which case we need to stop
  // displaying the rendering until they are valid again.
  public boolean isValid() {
    if (!guideTracker.isTracking()) return true;

    return guideTracker.isConsistentEndingWord();
  }

  /**
   * Line in the document this outline node is at.
   */
  public int getLine() {
    return guideTracker.isTracking() ? document.getLineNumber(guideTracker.getRange().getStartOffset()) : line;
  }

  public int getColumnForOffset(int offset) {
    assert (document != null);
    final int currentLine = document.getLineNumber(offset);
    return offset - document.getLineStartOffset(currentLine);
  }

  /*
   * Indent of the line to use for line visualization.
   *
   * This may intentionally differ from the column as for the line
   * `  child: Text(`
   * The indent will be 2 while the column is 9.
   */
  public int getIndent() {
    if (!guideTracker.isTracking()) {
      return indent;
    }
    final TextRange range = guideTracker.getRange();
    assert (range != null);
    return getColumnForOffset(range.getStartOffset());
  }

  /**
   * Column this outline node is at.
   * <p>
   * This is the column offset of the start of the widget constructor call.
   */
  public int getColumn() {
    if (!guideTracker.isTracking()) {
      return column;
    }
    final TextRange range = guideTracker.getRange();
    assert (range != null);
    return getColumnForOffset(Math.max(range.getStartOffset(), range.getEndOffset() - 1));
  }

  public TextRange getGuideTextRange() {
    return guideTracker.getRange();
  }

  public TextRange getFullRange() {
    return fullTracker.getRange();
  }

  @Override
  public int compareTo(OutlineLocation o) {
    // We use the initial location of the outline location when performing
    // comparisons rather than the current location for efficiency
    // and stability.
    int delta = Integer.compare(line, o.line);
    if (delta != 0) return delta;
    delta = Integer.compare(column, o.column);
    if (delta != 0) return delta;
    return Integer.compare(indent, o.indent);
  }
}