// Copyright 2006 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.util;

import static com.google.common.truth.Truth.assertThat;
import static org.junit.Assert.fail;

import com.google.common.annotations.VisibleForTesting;
import com.google.common.base.Joiner;
import com.google.common.truth.Ordered;
import com.google.devtools.build.lib.events.Event;
import com.google.devtools.build.lib.events.EventCollector;
import com.google.devtools.build.lib.events.EventHandler;
import com.google.devtools.build.lib.events.EventKind;
import com.google.devtools.build.lib.events.util.EventCollectionApparatus;
import com.google.devtools.build.lib.syntax.BazelLibrary;
import com.google.devtools.build.lib.syntax.BuildFileAST;
import com.google.devtools.build.lib.syntax.Environment;
import com.google.devtools.build.lib.syntax.Environment.Phase;
import com.google.devtools.build.lib.syntax.EvalException;
import com.google.devtools.build.lib.syntax.Expression;
import com.google.devtools.build.lib.syntax.Mutability;
import com.google.devtools.build.lib.syntax.Parser;
import com.google.devtools.build.lib.syntax.ParserInputSource;
import com.google.devtools.build.lib.syntax.SkylarkUtils;
import com.google.devtools.build.lib.syntax.Statement;
import com.google.devtools.build.lib.syntax.ValidationEnvironment;
import com.google.devtools.build.lib.testutil.TestConstants;
import com.google.devtools.build.lib.testutil.TestMode;
import java.util.LinkedList;
import java.util.List;
import org.junit.Before;

/**
 * Base class for test cases that use parsing and evaluation services.
 */
public class EvaluationTestCase {
  private EventCollectionApparatus eventCollectionApparatus =
      new EventCollectionApparatus(EventKind.ALL_EVENTS);
  private TestMode testMode = TestMode.SKYLARK;
  protected Environment env;
  protected Mutability mutability = Mutability.create("test");

  @Before
  public final void initialize() throws Exception {
    beforeInitialization();
    env = newEnvironment();
  }

  protected void beforeInitialization() throws Exception {
    // This method exists so that it can be overriden in MakeEnvironmentTest.
    // The problem is that MakeEnvironmentTest's initialization code (setting up makeEnvBuilder)
    // needs to run before initialize(), otherwise some tests fail with an NPE.
    // Consequently, we need this hack to ensure the right order of methods.
  }

  /**
   * Creates a standard Environment for tests in the BUILD language.
   * No PythonPreprocessing, mostly empty mutable Environment.
   */
  public Environment newBuildEnvironment() {
    Environment env =
        Environment.builder(mutability)
            .setGlobals(BazelLibrary.GLOBALS)
            .setEventHandler(getEventHandler())
            .setPhase(Phase.LOADING)
            .build();
    SkylarkUtils.setToolsRepository(env, TestConstants.TOOLS_REPOSITORY);
    return env;
  }

  /**
   * Creates an Environment for Skylark with a mostly empty initial environment.
   * For internal initialization or tests.
   */
  public Environment newSkylarkEnvironment() {
    return Environment.builder(mutability)
        .setGlobals(BazelLibrary.GLOBALS)
        .setEventHandler(getEventHandler())
        .build();
  }

  /**
   * Creates a new Environment suitable for the test case. Subclasses may override it
   * to fit their purpose and e.g. call newBuildEnvironment or newSkylarkEnvironment;
   * or they may play with the testMode to run tests in either or both kinds of Environment.
   * Note that all Environment-s may share the same Mutability, so don't close it.
   * @return a fresh Environment.
   */
  public Environment newEnvironment() throws Exception {
    if (testMode == null) {
      throw new IllegalArgumentException(
          "TestMode is null. Please set a Testmode via setMode() or set the "
              + "Environment manually by overriding newEnvironment()");
    }
    return testMode.createEnvironment(getEventHandler(), null);
  }

  /**
   * Sets the specified {@code TestMode} and tries to create the appropriate {@code Environment}
   * @param testMode
   * @throws Exception
   */
  protected void setMode(TestMode testMode) throws Exception {
    this.testMode = testMode;
    env = newEnvironment();
  }

  protected void enableSkylarkMode() throws Exception {
    setMode(TestMode.SKYLARK);
  }

  protected void enableBuildMode() throws Exception {
    setMode(TestMode.BUILD);
  }

  protected EventHandler getEventHandler() {
    return eventCollectionApparatus.reporter();
  }

  public Environment getEnvironment() {
    return env;
  }

  protected List<Statement> parseFile(String... input) {
    BuildFileAST ast = BuildFileAST.parseSkylarkString(getEventHandler(), input);
    ast = ast.validate(new ValidationEnvironment(env), getEventHandler());
    return ast.getStatements();
  }

  /** Parses an Expression from string without a supporting file */
  @VisibleForTesting
  public Expression parseExpression(String... input) {
    return Parser.parseExpression(
        ParserInputSource.create(Joiner.on("\n").join(input), null), getEventHandler());
  }

  public EvaluationTestCase update(String varname, Object value) throws Exception {
    env.update(varname, value);
    return this;
  }

  public Object lookup(String varname) throws Exception {
    return env.lookup(varname);
  }

  public Object eval(String... input) throws Exception {
    if (testMode == TestMode.SKYLARK) {
      return BuildFileAST.eval(env, input);
    }
    return BuildFileAST.parseBuildString(env.getEventHandler(), input).eval(env);
  }

  public void checkEvalError(String msg, String... input) throws Exception {
    setFailFast(true);
    try {
      eval(input);
      fail("Expected error '" + msg + "' but got no error");
    } catch (IllegalArgumentException | EvalException e) {
      assertThat(e).hasMessage(msg);
    }
  }

  public void checkEvalErrorContains(String msg, String... input) throws Exception {
    try {
      eval(input);
      fail("Expected error containing '" + msg + "' but got no error");
    } catch (IllegalArgumentException | EvalException e) {
      assertThat(e.getMessage()).contains(msg);
    }
  }

  // Forward relevant methods to the EventCollectionApparatus
  public EvaluationTestCase setFailFast(boolean failFast) {
    eventCollectionApparatus.setFailFast(failFast);
    return this;
  }

  public EvaluationTestCase assertNoWarningsOrErrors() {
    eventCollectionApparatus.assertNoWarningsOrErrors();
    return this;
  }

  public EventCollector getEventCollector() {
    return eventCollectionApparatus.collector();
  }

  public Event assertContainsError(String expectedMessage) {
    return eventCollectionApparatus.assertContainsError(expectedMessage);
  }

  public Event assertContainsWarning(String expectedMessage) {
    return eventCollectionApparatus.assertContainsWarning(expectedMessage);
  }

  public EvaluationTestCase clearEvents() {
    eventCollectionApparatus.clear();
    return this;
  }

  /**
   * Encapsulates a separate test which can be executed by a {@code TestMode}
   */
  protected interface Testable {
    public void run() throws Exception;
  }

  /**
   * Base class for test cases that run in specific modes (e.g. Build and/or Skylark)
   */
  protected abstract class ModalTestCase {
    private final SetupActions setup;

    protected ModalTestCase() {
      setup = new SetupActions();
    }

    /**
     * Allows the execution of several statements before each following test
     * @param statements The statement(s) to be executed
     * @return This {@code ModalTestCase}
     */
    public ModalTestCase setUp(String... statements) {
      setup.registerEval(statements);
      return this;
    }

    /**
     * Allows the update of the specified variable before each following test
     * @param name The name of the variable that should be updated
     * @param value The new value of the variable
     * @return This {@code ModalTestCase}
     */
    public ModalTestCase update(String name, Object value) {
      setup.registerUpdate(name, value);
      return this;
    }

    /**
     * Evaluates two parameters and compares their results.
     * @param statement The statement to be evaluated
     * @param expectedEvalString The expression of the expected result
     * @return This {@code ModalTestCase}
     * @throws Exception
     */
    public ModalTestCase testEval(String statement, String expectedEvalString) throws Exception {
      runTest(createComparisonTestable(statement, expectedEvalString, true));
      return this;
    }

    /**
     * Evaluates the given statement and compares its result to the expected object
     * @param statement
     * @param expected
     * @return This {@code ModalTestCase}
     * @throws Exception
     */
    public ModalTestCase testStatement(String statement, Object expected) throws Exception {
      runTest(createComparisonTestable(statement, expected, false));
      return this;
    }

    /**
     * Evaluates the given statement and compares its result to the collection of expected objects
     * without considering their order
     * @param statement The statement to be evaluated
     * @param items The expected items
     * @return This {@code ModalTestCase}
     * @throws Exception
     */
    public ModalTestCase testCollection(String statement, Object... items) throws Exception {
      runTest(collectionTestable(statement, false, items));
      return this;
    }

    /**
     * Evaluates the given statement and compares its result to the collection of expected objects
     * while considering their order
     * @param statement The statement to be evaluated
     * @param items The expected items, in order
     * @return This {@code ModalTestCase}
     * @throws Exception
     */
    public ModalTestCase testExactOrder(String statement, Object... items) throws Exception {
      runTest(collectionTestable(statement, true, items));
      return this;
    }

    /**
     * Evaluates the given statement and checks whether the given error message appears
     * @param expectedError The expected error message
     * @param statements The statement(s) to be evaluated
     * @return This ModalTestCase
     * @throws Exception
     */
    public ModalTestCase testIfExactError(String expectedError, String... statements)
        throws Exception {
      runTest(errorTestable(true, expectedError, statements));
      return this;
    }

    /**
     * Evaluates the given statement and checks whether an error that contains the expected message
     * occurs
     * @param expectedError
     * @param statements
     * @return This ModalTestCase
     * @throws Exception
     */
    public ModalTestCase testIfErrorContains(String expectedError, String... statements)
        throws Exception {
      runTest(errorTestable(false, expectedError, statements));
      return this;
    }

    /**
     * Looks up the value of the specified variable and compares it to the expected value
     * @param name
     * @param expected
     * @return This ModalTestCase
     * @throws Exception
     */
    public ModalTestCase testLookup(String name, Object expected) throws Exception {
      runTest(createLookUpTestable(name, expected));
      return this;
    }

    /**
     * Creates a Testable that checks whether the evaluation of the given statement leads to the
     * expected error
     * @param statements
     * @param error
     * @param exactMatch If true, the error message has to be identical to the expected error
     * @return An instance of Testable that runs the error check
     */
    protected Testable errorTestable(
        final boolean exactMatch, final String error, final String... statements) {
      return new Testable() {
        @Override
        public void run() throws Exception {
          if (exactMatch) {
            checkEvalError(error, statements);
          } else {
            checkEvalErrorContains(error, statements);
          }
        }
      };
    }

    /**
     * Creates a testable that checks whether the evaluation of the given statement leads to a list
     * that contains exactly the expected objects
     * @param statement The statement to be evaluated
     * @param ordered Determines whether the order of the elements is checked as well
     * @param expected Expected objects
     * @return An instance of Testable that runs the check
     */
    protected Testable collectionTestable(
        final String statement, final boolean ordered, final Object... expected) {
      return new Testable() {
        @Override
        public void run() throws Exception {
          Ordered tmp = assertThat((Iterable<?>) eval(statement)).containsExactly(expected);

          if (ordered) {
            tmp.inOrder();
          }
        }
      };
    }

    /**
     * Creates a testable that compares the evaluation of the given statement to a specified result
     *
     * @param statement The statement to be evaluated
     * @param expected Either the expected object or an expression whose evaluation leads to the
     *  expected object
     * @param expectedIsExpression Signals whether {@code expected} is an object or an expression
     * @return An instance of Testable that runs the comparison
     */
    protected Testable createComparisonTestable(
        final String statement, final Object expected, final boolean expectedIsExpression) {
      return new Testable() {
        @Override
        public void run() throws Exception {
          Object actual = eval(statement);
          Object realExpected = expected;

          // We could also print the actual object and compare the string to the expected
          // expression, but then the order of elements would matter.
          if (expectedIsExpression) {
            realExpected = eval((String) expected);
          }

          assertThat(actual).isEqualTo(realExpected);
        }
      };
    }

    /**
     * Creates a Testable that looks up the given variable and compares its value to the expected
     * value
     * @param name
     * @param expected
     * @return An instance of Testable that does both lookup and comparison
     */
    protected Testable createLookUpTestable(final String name, final Object expected) {
      return new Testable() {
        @Override
        public void run() throws Exception {
          assertThat(lookup(name)).isEqualTo(expected);
        }
      };
    }

    /**
     * Executes the given Testable
     * @param testable
     * @throws Exception
     */
    protected void runTest(Testable testable) throws Exception {
      run(new TestableDecorator(setup, testable));
    }

    protected abstract void run(Testable testable) throws Exception;
  }

  /**
   * A simple decorator that allows the execution of setup actions before running a {@code Testable}
   */
  static class TestableDecorator implements Testable {
    private final SetupActions setup;
    private final Testable decorated;

    public TestableDecorator(SetupActions setup, Testable decorated) {
      this.setup = setup;
      this.decorated = decorated;
    }

    /**
     * Executes all stored actions and updates plus the actual {@code Testable}
     */
    @Override
    public void run() throws Exception {
      setup.executeAll();
      decorated.run();
    }
  }

  /**
   * A container for collection actions that should be executed before a test
   */
  class SetupActions {
    private List<Testable> setup;

    public SetupActions() {
      setup = new LinkedList<>();
    }

    /**
     * Registers a variable that has to be updated before a test
     *
     * @param name
     * @param value
     */
    public void registerUpdate(final String name, final Object value) {
      setup.add(
          new Testable() {
            @Override
            public void run() throws Exception {
              EvaluationTestCase.this.update(name, value);
            }
          });
    }

    /**
     * Registers a statement for evaluation prior to a test
     *
     * @param statements
     */
    public void registerEval(final String... statements) {
      setup.add(
          new Testable() {
            @Override
            public void run() throws Exception {
              EvaluationTestCase.this.eval(statements);
            }
          });
    }

    /**
     * Executes all stored actions and updates
     * @throws Exception
     */
    public void executeAll() throws Exception {
      for (Testable testable : setup) {
        testable.run();
      }
    }
  }

  /**
   * A class that executes each separate test in both modes (Build and Skylark)
   */
  protected class BothModesTest extends ModalTestCase {
    public BothModesTest() {}

    /**
     * Executes the given Testable in both Build and Skylark mode
     */
    @Override
    protected void run(Testable testable) throws Exception {
      enableSkylarkMode();
      try {
        testable.run();
      } catch (Exception e) {
        throw new Exception("While in Skylark mode", e);
      }

      enableBuildMode();
      try {
        testable.run();
      } catch (Exception e) {
        throw new Exception("While in Build mode", e);
      }
    }
  }

  /**
   * A class that runs all tests in Build mode
   */
  protected class BuildTest extends ModalTestCase {
    public BuildTest() {}

    @Override
    protected void run(Testable testable) throws Exception {
      enableBuildMode();
      testable.run();
    }
  }

  /**
   * A class that runs all tests in Skylark mode
   */
  protected class SkylarkTest extends ModalTestCase {
    public SkylarkTest() {}

    @Override
    protected void run(Testable testable) throws Exception {
      enableSkylarkMode();
      testable.run();
    }
  }
}
