// Copyright 2020 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 net.starlark.java.eval;

import static java.nio.charset.StandardCharsets.UTF_8;

import com.google.common.base.Splitter;
import com.google.common.collect.ImmutableMap;
import com.google.common.io.Files;
import java.io.File;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import net.starlark.java.annot.Param;
import net.starlark.java.annot.StarlarkMethod;
import net.starlark.java.syntax.FileOptions;
import net.starlark.java.syntax.ParserInput;
import net.starlark.java.syntax.SyntaxError;

/** Script-based tests of Starlark evaluator. */
public final class ScriptTest {

  // Tests for Starlark.
  //
  // In each test file, chunks are separated by "\n---\n".
  // Each chunk is evaluated separately.
  // Use "###" to specify the expected error.
  // If there is no "###", the test will succeed iff there is no error.
  //
  // Within the file, the assert_ and assert_eq functions may be used to
  // report errors without stopping the program. (They are not evaluation
  // errors that can be caught with a '###' expectation.)

  // TODO(adonovan): improve this test driver (following go.starlark.net):
  //
  // - use a proper quotation syntax (Starlark string literals) in '### "foo"' expectations.
  // - extract support for "chunked files" into a library
  //   and reuse it for tests of lexer, parser, resolver.
  // - separate static tests entirely. They can use the same
  //   notation, but we shouldn't be mixing static and dynamic tests.
  // - don't interpret the pattern as "either a substring or a regexp".
  //   Be consistent: always use regexp.
  // - require that some frame of each EvalError match the file/line of the expectation.

  interface Reporter {
    void reportError(StarlarkThread thread, String message);
  }

  @StarlarkMethod(
      name = "assert_",
      documented = false,
      parameters = {
        @Param(name = "cond", noneable = true),
        @Param(name = "msg", defaultValue = "'assertion failed'"),
      },
      useStarlarkThread = true)
  public Object assertStarlark(Object cond, String msg, StarlarkThread thread)
      throws EvalException {
    if (!Starlark.truth(cond)) {
      thread.getThreadLocal(Reporter.class).reportError(thread, "assert_: " + msg);
    }
    return Starlark.NONE;
  }

  @StarlarkMethod(
      name = "assert_eq",
      documented = false,
      parameters = {
        @Param(name = "x", noneable = true),
        @Param(name = "y", noneable = true),
      },
      useStarlarkThread = true)
  public Object assertEq(Object x, Object y, StarlarkThread thread) throws EvalException {
    // TODO(adonovan): use Starlark.equals.
    if (!x.equals(y)) {
      String msg = String.format("assert_eq: %s != %s", Starlark.repr(x), Starlark.repr(y));
      thread.getThreadLocal(Reporter.class).reportError(thread, msg);
    }
    return Starlark.NONE;
  }

  private static boolean ok = true;

  public static void main(String[] args) throws Exception {
    File root = new File("third_party/bazel"); // blaze
    if (!root.exists()) {
      root = new File("."); // bazel
    }
    File testdata = new File(root, "src/test/java/net/starlark/java/eval/testdata");
    for (String name : testdata.list()) {
      File file = new File(testdata, name);
      String content = Files.asCharSource(file, UTF_8).read();
      int linenum = 1;
      for (String chunk : Splitter.on("\n---\n").split(content)) {
        // prepare chunk
        StringBuilder buf = new StringBuilder();
        for (int i = 1; i < linenum; i++) {
          buf.append('\n');
        }
        buf.append(chunk);
        if (false) {
          System.err.printf("%s:%d: <<%s>>\n", file, linenum, buf);
        }

        // extract "### string" expectations
        Map<String, Integer> expectations = new HashMap<>();
        for (int i = 0; true; i += "###".length()) {
          i = chunk.indexOf("###", i);
          if (i < 0) {
            break;
          }
          int j = chunk.indexOf("\n", i);
          if (j < 0) {
            j = chunk.length();
          }
          String pattern = chunk.substring(i + 3, j).trim();
          int line = linenum + newlines(chunk.substring(0, i));
          if (false) {
            System.err.printf("%s:%d: expectation '%s'\n", file, line, pattern);
          }
          expectations.put(pattern, line);
        }

        // parse & execute
        ParserInput input = ParserInput.fromString(buf.toString(), file.toString());
        ImmutableMap.Builder<String, Object> predeclared = ImmutableMap.builder();
        Starlark.addMethods(predeclared, new ScriptTest()); // e.g. assert_eq
        StarlarkSemantics semantics = StarlarkSemantics.DEFAULT;
        Module module = Module.withPredeclared(semantics, predeclared.build());
        try (Mutability mu = Mutability.create("test")) {
          StarlarkThread thread = new StarlarkThread(mu, semantics);
          thread.setThreadLocal(Reporter.class, ScriptTest::reportError);
          Starlark.execFile(input, FileOptions.DEFAULT, module, thread);

        } catch (SyntaxError.Exception ex) {
          // parser/resolver errors
          for (SyntaxError err : ex.errors()) {
            // TODO(adonovan): don't allow expectations to match static errors;
            // they should be a different test suite. It is dangerous to mix
            // them in a chunk otherwise the presence of a static error causes
            // the program not to run the dynamic assertions.
            if (!expected(expectations, err.message())) {
              System.err.println(err); // includes location
              ok = false;
            }
          }

        } catch (EvalException ex) {
          // evaluation error
          //
          // TODO(adonovan): the old logic checks only that each error is matched
          // by at least one expectation. Instead, ensure that errors
          // and expections match exactly. Furthermore, look only at errors
          // whose stack has a frame with a file/line that matches the expectation.
          // This requires inspecting EvalException stack.
          if (!expected(expectations, ex.getMessage())) {
            System.err.println(ex.getMessageWithStack());
            ok = false;
          }

        } catch (Throwable ex) {
          // unhandled exception (incl. InterruptedException)
          System.err.printf(
              "%s:%d: unhandled %s in this chunk: %s\n",
              file, linenum, ex.getClass().getSimpleName(), ex.getMessage());
          ex.printStackTrace();
          ok = false;
        }

        // unmatched expectations
        for (Map.Entry<String, Integer> e : expectations.entrySet()) {
          System.err.printf("%s:%d: unmatched expectation: %s\n", file, e.getValue(), e.getKey());
          ok = false;
        }

        // advance line number
        linenum += newlines(chunk) + 2; // for "\n---\n"
      }
    }
    if (!ok) {
      System.exit(1);
    }
  }

  // Called by assert_ and assert_eq when the test encounters an error.
  // Does not stop the program; multiple failures may be reported in a single run.
  private static void reportError(StarlarkThread thread, String message) {
    System.err.printf("Traceback (most recent call last):\n");
    List<StarlarkThread.CallStackEntry> stack = thread.getCallStack();
    stack = stack.subList(0, stack.size() - 1); // pop the built-in function
    for (StarlarkThread.CallStackEntry fr : stack) {
      System.err.printf("%s: called from %s\n", fr.location, fr.name);
    }
    System.err.println("Error: " + message);
    ok = false;
  }

  private static boolean expected(Map<String, Integer> expectations, String message) {
    for (String pattern : expectations.keySet()) {
      if (message.contains(pattern) || message.matches(".*" + pattern + ".*")) {
        expectations.remove(pattern);
        return true;
      }
    }
    return false;
  }

  private static int newlines(String s) {
    int n = 0;
    for (int i = 0; i < s.length(); i++) {
      if (s.charAt(i) == '\n') {
        n++;
      }
    }
    return n;
  }
}
