// Copyright 2015 The Bazel Authors. 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.devtools.build.lib.syntax;

import com.google.common.base.Joiner;
import com.google.devtools.build.lib.events.Location;
import com.google.devtools.build.lib.util.Preconditions;
import java.util.Deque;
import java.util.LinkedList;
import java.util.Objects;

/**
 * EvalException with a stack trace.
 */
public class EvalExceptionWithStackTrace extends EvalException {

  private StackTraceElement mostRecentElement;

  public EvalExceptionWithStackTrace(Exception original, ASTNode culprit) {
    super(extractLocation(original, culprit), getNonEmptyMessage(original), getCause(original));
    registerNode(culprit);
  }

  @Override
  public boolean canBeAddedToStackTrace() {
    // Doesn't make any sense to add this exception to another instance of
    // EvalExceptionWithStackTrace.
    return false;
  }

  /**
   * Returns the appropriate location for this exception.
   *
   * <p>If the {@code ASTNode} has a valid location, this one is used. Otherwise, we try to get the
   * location of the exception.
   */
  private static Location extractLocation(Exception original, ASTNode culprit) {
    if (culprit != null && culprit.getLocation() != null) {
      return culprit.getLocation();
    }
    return (original instanceof EvalException) ? ((EvalException) original).getLocation() : null;
  }

  /**
   * Returns the "real" cause of this exception.
   *
   * <p>If the original exception is an EvalException, its cause is returned.
   * Otherwise, the original exception itself is seen as the cause for this exception.
   */
  private static Throwable getCause(Exception ex) {
    return (ex instanceof EvalException) ? ex.getCause() : ex;
  }

  /**
   * Adds an entry for the given {@code ASTNode} to the stack trace.
   */
  public void registerNode(ASTNode node) {
    addStackFrame(node.toString().trim(), node.getLocation());
  }

  /**
   * Makes sure the stack trace is rooted in a function call.
   *
   * In some cases (rule implementation application, aspect implementation application)
   * bazel calls into the function directly (using BaseFunction.call). In that case, since
   * there is no FuncallExpression to evaluate, stack trace mechanism cannot record this call.
   * This method allows to augument the stack trace with information about the call.
   */
  public void registerPhantomFuncall(
      String funcallDescription, Location location, BaseFunction function) {
    /*
     *
     * We add two new frames to the stack:
     * 1. Pseudo-function call (for example, rule definition)
     * 2. Function entry (Rule implementation)
     *
     * Similar to Python, all functions that were entered (except for the top-level ones) appear
     * twice in the stack trace output. This would lead to the following trace:
     *
     * File BUILD, line X, in <module>
     *     rule_definition()
     * File BUILD, line X, in rule_definition
     *     rule_implementation()
     * File bzl, line Y, in rule_implementation
     *     ...
     *
     * Please note that lines 3 and 4 are quite confusing since a) the transition from
     * rule_definition to rule_implementation happens internally and b) the locations do not make
     * any sense.
     * Consequently, we decided to omit lines 3 and 4 from the output via canPrint = false:
     *
     * File BUILD, line X, in <module>
     *     rule_definition()
     * File bzl, line Y, in rule_implementation
     *     ...
     *
     * */
    addStackFrame(function.getName(), function.getLocation());
    addStackFrame(funcallDescription, location, false);
  }

  /**
   * Adds a line for the given frame.
   */
  private void addStackFrame(String label, Location location, boolean canPrint) {
    // We have to watch out for duplicate since ExpressionStatements add themselves twice:
    // Statement#exec() calls Expression#eval(), both of which call this method.
    if (mostRecentElement != null && isSameLocation(location, mostRecentElement.getLocation())) {
      return;
    }
    mostRecentElement = new StackTraceElement(label, location, mostRecentElement, canPrint);
  }

  /**
   * Checks two locations for equality in paths and start offsets.
   *
   * <p> LexerLocation#equals cannot be used since it cares about different end offsets.
   */
  private boolean isSameLocation(Location first, Location second) {
    try {
      return Objects.equals(first.getPath(), second.getPath())
          && Objects.equals(first.getStartOffset(), second.getStartOffset());
    } catch (NullPointerException ex) {
      return first == second;
    }
  }

  private void addStackFrame(String label, Location location)   {
    addStackFrame(label, location, true);
  }

  /**
   * Returns the exception message without the stack trace.
   */
  public String getOriginalMessage() {
    return super.getMessage();
  }

  @Override
  public String getMessage() {
    return print();
  }

  @Override
  public String print() {
    // Currently, we do not limit the text length per line.
    return print(StackTracePrinter.INSTANCE);
  }

  /**
   *  Prints the stack trace iff it contains more than just one built-in function.
   */
  public String print(StackTracePrinter printer) {
    return canPrintStackTrace()
        ? printer.print(getOriginalMessage(), mostRecentElement)
        : getOriginalMessage();
  }

  /**
   * Returns true when there is at least one non-built-in element.
   */
  protected boolean canPrintStackTrace() {
    return mostRecentElement != null && mostRecentElement.getCause() != null;
  }

  /**
   * An element in the stack trace which contains the name of the offending function / rule /
   * statement and its location.
   */
  protected static final class StackTraceElement {
    private final String label;
    private final Location location;
    private final StackTraceElement cause;
    private final boolean canPrint;

    StackTraceElement(String label, Location location, StackTraceElement cause, boolean canPrint) {
      this.label = label;
      this.location = location;
      this.cause = cause;
      this.canPrint = canPrint;
    }

    String getLabel() {
      return label;
    }

    Location getLocation() {
      return location;
    }

    StackTraceElement getCause() {
      return cause;
    }

    boolean canPrint() {
      return canPrint;
    }

    @Override
    public String toString() {
      return String.format(
          "%s @ %s -> %s", label, location, (cause == null) ? "null" : cause.toString());
    }
  }

  /**
   * Singleton class that prints stack traces similar to Python.
   */
  public enum StackTracePrinter {
    INSTANCE;

    /**
     * Turns the given message and StackTraceElements into a string.
     */
    public final String print(String message, StackTraceElement mostRecentElement) {
      Deque<String> output = new LinkedList<>();

      // Adds dummy element for the rule call that uses the location of the top-most function.
      mostRecentElement = new StackTraceElement("", mostRecentElement.getLocation(),
          (mostRecentElement.getCause() == null) ? null : mostRecentElement, true);

      while (mostRecentElement != null) {
        if (mostRecentElement.canPrint()) {
          String entry = print(mostRecentElement);
          if (entry != null && entry.length() > 0) {
            addEntry(output, entry);
          }
        }

        mostRecentElement = mostRecentElement.getCause();
      }

      addMessage(output, message);
      return Joiner.on(System.lineSeparator()).join(output);
    }

    /**
     * Returns the location of the given element or Location.BUILTIN if the element is null.
     */
    private Location getLocation(StackTraceElement element) {
      return (element == null) ? Location.BUILTIN : element.getLocation();
    }

    /**
     * Returns the string representation of the given element.
     */
    protected String print(StackTraceElement element) {
      // Similar to Python, the first (most-recent) entry in the stack frame is printed only once.
      // Consequently, we skip it here.
      if (element.getCause() == null) {
        return "";
      }

      // Prints a two-line string, similar to Python.
      Location location = getLocation(element.getCause());
      return String.format(
          "\tFile \"%s\", line %d%s%n\t\t%s",
          printPath(location),
          getLine(location),
          printFunction(element.getLabel()),
          element.getCause().getLabel());
    }

    private String printFunction(String func) {
      if (func.isEmpty()) {
        return "";
      }

      int pos = func.indexOf('(');
      return String.format(", in %s", (pos < 0) ? func : func.substring(0, pos));
    }

    private String printPath(Location loc) {
      return (loc == null || loc.getPath() == null) ? "<unknown>" : loc.getPath().getPathString();
    }

    private int getLine(Location loc) {
      return (loc == null || loc.getStartLineAndColumn() == null)
          ? 0 : loc.getStartLineAndColumn().getLine();
    }

    /**
     * Adds the given string to the specified Deque.
     */
    protected void addEntry(Deque<String> output, String toAdd) {
      output.addLast(toAdd);
    }

    /**
     * Adds the given message to the given output dequeue after all stack trace elements have been
     * added.
     */
    protected void addMessage(Deque<String> output, String message) {
      output.addFirst("Traceback (most recent call last):");
      output.addLast(message);
    }
  }

  /**
   * Returns a non-empty message for the given exception.
   *
   * <p> If the exception itself does not have a message, a new message is constructed from the
   * exception's class name.
   * For example, an IllegalArgumentException will lead to "Illegal Argument".
   * Additionally, the location in the Java code will be added, if applicable,
   */
  private static String getNonEmptyMessage(Exception original) {
    Preconditions.checkNotNull(original);
    String msg = original.getMessage();
    if (msg != null && !msg.isEmpty()) {
      return msg;
    }

    char[] name = original.getClass().getSimpleName().replace("Exception", "").toCharArray();
    boolean first = true;
    StringBuilder builder = new StringBuilder();

    for (char current : name) {
      if (Character.isUpperCase(current) && !first) {
        builder.append(" ");
      }
      builder.append(current);
      first = false;
    }

    java.lang.StackTraceElement[] trace = original.getStackTrace();
    if (trace.length > 0) {
      builder.append(String.format(": %s.%s() in %s:%d", getShortClassName(trace[0]),
          trace[0].getMethodName(), trace[0].getFileName(), trace[0].getLineNumber()));
    }

    return builder.toString();
  }

  private static String getShortClassName(java.lang.StackTraceElement element) {
    String name = element.getClassName();
    int pos = name.lastIndexOf('.');
    return (pos < 0) ? name : name.substring(pos + 1);
  }
}
