/*
 * RiskScape™ Copyright New Zealand Institute for Earth Science Limited
 * (Earth Sciences New Zealand) is distributed for research purposes only
 * under the terms of AGPLv3.
 *
 * RiskScape™ Copyright 2026 New Zealand Institute for Earth Science
 * Limited (Earth Sciences New Zealand). All rights reserved. Source code
 * available under the AGPLv3.
 * 
 * This program is free software: you can redistribute it and/or modify it under
 *  the terms of the GNU Affero General Public License as published by the Free
 *  Software Foundation, either version 3 of the License, or (at your option) any
 *  later version.
 * 
 * This program is distributed for RESEARCH PURPOSES ONLY, in the hope that it will
 * be useful for research and education initiatives.
 * 
 * If you are not a researcher, or you are a researcher who wishes to use this
 * program on terms other than AGPLv3 (including those who wish to restrict the
 * distribution of any source code created using this program), please contact:
 * https://riskscape.org.nz
 * 
 * This program is distributed WITHOUT ANY WARRANTY; without even the implied
 * warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
 * Affero General Public License for more details.  You should have received a copy
 * of the GNU Affero General Public License along with this program.  If not, see
 * <http://www.gnu.org/licenses/>.
 * 
 * By way of summary only, under the AGPLv3:
 *     • Permissions of this strongest copyleft license are conditioned
 *       on making available complete source code of licensed works and
 *       modifications, which include larger works using a licensed work,
 *       under the same license.
 *     • Copyright and license notices must be preserved.
 *     • Contributors provide an express grant of patent rights.
 *     • When a modified version is used to provide a service over a
 *       network, the complete source code of the modified version must be made
 *       available.
 */
package nz.org.riskscape.engine;

import static org.junit.Assert.*;

import java.net.MalformedURLException;
import java.net.URI;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.List;
import java.util.Locale;
import java.util.UUID;
import java.util.stream.Collectors;

import org.hamcrest.Matcher;
import org.hamcrest.core.IsAnything;
import org.hamcrest.core.IsSame;
import org.junit.Before;

import nz.org.riskscape.config.Config;
import nz.org.riskscape.config.ini.IniConfig;
import nz.org.riskscape.config.ini.IniConfigSection;
import nz.org.riskscape.engine.bind.BindingContext;
import nz.org.riskscape.engine.bind.DefaultBindingContext;
import nz.org.riskscape.engine.core.EnginePlugin;
import nz.org.riskscape.engine.data.Bookmark;
import nz.org.riskscape.engine.data.PickledDataBookmarkResolver;
import nz.org.riskscape.engine.data.ResolvedBookmark;
import nz.org.riskscape.engine.i18n.DefaultMessages;
import nz.org.riskscape.engine.i18n.ResourceClassLoader;
import nz.org.riskscape.engine.ini.IniFileFunctionBuilder;
import nz.org.riskscape.engine.pipeline.ExecutionContext;
import nz.org.riskscape.engine.plugin.BuiltInPluginDescriptor;
import nz.org.riskscape.engine.plugin.ExtensionPoints;
import nz.org.riskscape.engine.plugin.Plugin;
import nz.org.riskscape.engine.plugin.PluginRepository;
import nz.org.riskscape.engine.resource.CustomProtocolTestingResourceLoader;
import nz.org.riskscape.engine.rl.ExpressionRealizer;
import nz.org.riskscape.engine.rl.RealizationContext;
import nz.org.riskscape.engine.spi.EngineBootstrapper;
import nz.org.riskscape.engine.types.Struct;
import nz.org.riskscape.engine.types.Type;
import nz.org.riskscape.engine.types.TypeRegistry;
import nz.org.riskscape.ini.IniFile;
import nz.org.riskscape.ini.IniParser2;
import nz.org.riskscape.problem.Problem;
import nz.org.riskscape.problem.Problem.Severity;
import nz.org.riskscape.problem.ProblemSink;
import nz.org.riskscape.problem.Problems;
import nz.org.riskscape.rl.ExpressionParser;
import nz.org.riskscape.test.TestUtils;

// TODO make this an engine test to avoid reinventing test harness code in a zillion places
public abstract class ProjectTest {

  public static final URI PROJECT_URI = URI.create("test:/project.ini");

  // This used to be riskscape.org.nz in all the tests, but now we've switched docs to a microsite
  // we don't proxy these anymore
  public static final String HTTPS_TEST_URI =
      "object-storage.nz-hlz-1.catalystcloud.io/v1/AUTH_aa20cc6a73bd4a27a1b0348a5704ae7f/rel";
  public final List<Problem> sunkProblems = new ArrayList<>();
  public CustomProtocolTestingResourceLoader customProtocolTestingResourceLoader =
      new CustomProtocolTestingResourceLoader("test");

  public Config config = Config.EMPTY;
  public ExtensionPoints extensionPoints = new ExtensionPoints();
  public DefaultEngine engine = createEngine();
  public DefaultProject project = createProject();
  public ExpressionParser expressionParser = ExpressionParser.INSTANCE;
  public PickledDataBookmarkResolver bookmarkResolver = new PickledDataBookmarkResolver();
  public ExecutionContext executionContext = project.newExecutionContext();
  public RealizationContext realizationContext = executionContext.getRealizationContext();
  public ExpressionRealizer expressionRealizer = realizationContext.getExpressionRealizer();
  public BindingContext bindingContext = executionContext.getBindingContext().withNewRelativeTo(Paths.get(".").toUri());
  public DefaultMessages messages = new DefaultMessages(extensionPoints);

  public ProjectTest() {
    try {
      // we need to add useful things to identified collections in a way that ensures they are only added
      // once (you get failures on subsequent adds). Otherwise these failures will scupper the
      // RetryRule's attempts to rerun tests on failure
      addPickledDataBookmarkResolver();
      addDefaultTypesToRegistry();
    } catch (Exception e) {
      throw new RuntimeException(e);
    }
  }

  @Before
  public void clearSunkProblems() {
    // sunkProblems need to be cleared to prevent problems accumulating on retry attempts.
    sunkProblems.clear();
  }

  public void addPickledDataBookmarkResolver() {
    engine.getBookmarkResolvers().add(bookmarkResolver);
  }

  /**
   * Add something to the project's bookmarks, returning a uniquely generated id you can use to refer to it
   */
  public String addPickledData(Object data) {
    String uuid = UUID.randomUUID().toString();
    addPickledData(uuid, data);
    return uuid;
  }

  /**
   * Add a ResolvedBookmark to the project's bookmarks and pickled bookmark resolver so that it always returns the given
   * thing.  Use {@link #addPickledData(Object)} if you don't need to play with validation or data errors.
   */
  public ResolvedBookmark addPickledBookmark(ResolvedBookmark resolvedBookmark) {
    bookmarkResolver.add(resolvedBookmark);

    // some tests will want the corresponding bookmark to be in the project, too, and it's mostly harmless
    if (!project.getBookmarks().containsKey(resolvedBookmark.getId())) {
      project.getBookmarks().add(resolvedBookmark.getBookmark());
    }
    return resolvedBookmark;
  }

  /**
   * Add data to the project's bookmarks with the given bookmark `id` that always resolves to the given `data`
   * object
   */
  public ResolvedBookmark addPickledData(String id, Object data) {
    return addPickledBookmark(ResolvedBookmark.withId(id, data));

  }
  /**
   * Returns the Path(s) to find any i18n resources that the tests might need (e.g. for rendering problems)
   */
  public List<Path> getI18nResourcePaths() {
    return Arrays.asList(TestUtils.getRootProjectPath().get()
        .resolve(Paths.get("engine", "core", "src", "main", "i18n")));
  }

  @Before
  public final void setupI18nProblemMessages() throws MalformedURLException {
    for (Path path : getI18nResourcePaths()) {
      // KLUDGE: See comment on TestUtils#getApiClassResourcesURL
      messages.getClassLoader().append(this,
          new ResourceClassLoader(
              TestUtils.getApiClassResourcesURL()));

      messages.getClassLoader().append(this,
          new ResourceClassLoader("", path.toUri().toURL()));
    }
  }

  protected URI functionSource = URI.create("file:/foo/bar.txt");

  protected void addFunction(String name, String functionDefinition) {
    functionDefinition = String.format("""
        [function %s]
        framework = expression
        %s
        """,  name, functionDefinition);

    IniFileFunctionBuilder builder = new IniFileFunctionBuilder(project);
    IniParser2 parser = new IniParser2();
    IniFile iniFile = parser.parse(functionDefinition);

    builder.addToProject(project, name, new IniConfigSection(functionSource, name, iniFile.getSections().get(0)));
  }

  public String render(List<Problem> problems) {
    return problems.stream().map(p -> render(p)).collect(Collectors.joining(OsUtils.LINE_SEPARATOR));
  }

  public String render(Problem problem) {
    return messages.renderProblem(problem, Locale.getDefault()).toString();
  }

  public String render(Throwable throwable) {
    return render(Problems.caught(throwable));
  }

  /**
   * For String-based Problem matching using the rendered/translated message text for
   * i18n ProblemCode-based Problems. Note that the rendered message text includes
   * *any children* that the Problem might have.
   */
  public Matcher<Problem> isProblemShown(Severity severity, Matcher<String> messageMatcher) {
    return Matchers.isProblem(
        new IsSame<Severity>(severity),
        messageMatcher,
        IsAnything.anything(),
        p -> render(p));
  }

  protected DefaultProject createProject() {
    return new DefaultProject(engine, config);
  }

  public List<Plugin> getPlugins() {
    return Arrays.asList(BuiltInPluginDescriptor.testPlugin(EnginePlugin.class));
  }

  /**
   * Add any features to this.extensionPoints prior to the engine being created
   */
  public void addFeatures() {

  }

  /**
   * Add any ExtensionPoint objects to this.extensionPoints prior to the engine being created
   */
  public void addExtensionsPoints() {

  }

  public DefaultEngine createEngine() {
    List<Plugin> plugins = getPlugins();
    PluginRepository.collectFeatures(this.extensionPoints, plugins);

    addExtensionsPoints();
    addFeatures();

    DefaultEngine newEngine = new DefaultEngine(BuildInfo.UNKNOWN, plugins, extensionPoints, Collections.emptyMap());
    // Note that this is one of the few engine collections that get populated by default - binders were previously not
    // in an identified collection, and a lot of tests have come to expect them to be there by default, so we set them
    populateEngineWithDefaults(newEngine);
    return newEngine;
  }

  protected void populateEngineWithDefaults(DefaultEngine newEngine) {
    EngineBootstrapper.bootstrap(extensionPoints, newEngine);
    newEngine.getBinders().addAll(DefaultBindingContext.DEFAULT_BINDERS);
    newEngine.setProblemSink(p -> sunkProblems.add(p));
    newEngine.getResourceFactory().add(customProtocolTestingResourceLoader);
  }


  public Path getPackagePath() {
    String packageName = getClass().getPackage().getName();
    return Paths.get("src/test/resources", packageName.split("\\."));
  }

  public ResolvedBookmark addLocalData(String basenameAndId, Class<?> requiredType) {
    Path packagePath = getPackagePath();
    Path resource = packagePath.resolve(basenameAndId);

    if (!resource.toFile().exists()) {
      fail("Path does not exist: " + resource);
    }

    Bookmark bookmark = Bookmark.builder().id(basenameAndId).location(resource.toUri()).build();
    ResolvedBookmark resolved =
        engine.getBookmarkResolvers().resolveAndValidate(bookmark, executionContext, requiredType)
        .orElseThrow(problems -> new RuntimeException(Problem.debugString(problems)));

    project.getBookmarks().add(bookmark);

    return resolved;
  }

  public void addDefaultTypesToRegistry() throws Exception {
    TypeRegistry.addDefaults(engine.getTypeRegistry());
  }

  public void loadConfig(String path, ProblemSink warningSink, String content) {
    customProtocolTestingResourceLoader.addContent(path, content);
    IniFile ini = new IniParser2().parse(content);
    config = IniConfig.fromIniFile(URI.create("test:" + path), ini, warningSink);
  }

  /**
   * Re-initialize the project using the given project, but stops short of populating it using a project builder
   */
  public void config(String path, ProblemSink warningSink, String... strings) {
    String content = String.join("\n", strings);
    loadConfig(path, warningSink, content);
    project = createProject();
  }

  public void config(ProblemSink warningSink, String... strings) {
    config("/project.ini", warningSink, strings);
  }

  public void config(String... strings) {
    config(p -> fail("Unexpected warning: " + p), strings);
  }

  public List<Problem> populateWithLocation(String location, String... configLines) {
    config(location, p -> fail("Unexpected warning: " + p), configLines);
    return new DefaultProjectBuilder().init(project).getProblems();
  }

  public List<Problem> populate(String... configLines) {
    return populate(p -> fail("Unexpected warning: " + p), configLines);
  }

  public List<Problem> populate(ProblemSink warningSink, String... configLines) {
    config(warningSink, configLines);
    return new DefaultProjectBuilder().init(project).getProblems();
  }

  /**
   * Define a struct using a type expression
   */
  public Struct struct(String typeExpression) {
    return project.getTypeBuilder().build("struct(" + typeExpression + ")").asStruct();
  }

  /**
   * Construct a tuple from a riskscape expression - quicker than using java code... (but then what isn't?)
   */
  public Tuple tuple(String expression) {
    return (Tuple) expressionRealizer.realize(Struct.EMPTY_STRUCT, expression).get().evaluate(Tuple.EMPTY_TUPLE);
  }

  /**
   * Construct a list of tuples from a RiskScape expression (useful for assertions)
   */
  @SuppressWarnings("unchecked")
  public List<Tuple> tuples(String expression) {
    Object result = expressionRealizer.realize(Struct.EMPTY_STRUCT, expression).get().evaluate(Tuple.EMPTY_TUPLE);

    if (result instanceof List<?> list) {
      if (list.isEmpty() || list.get(0) instanceof Tuple) {
        return (List<Tuple>) list;
      } else {
        throw new AssertionError("List from expression " + expression + " does not contain tuples: " + list);
      }
    } else {
      throw new AssertionError("Expression " + expression + " does not return a list: " + result);
    }
  }

  /**
   * Construct a tuple from a riskscape expression, making sure the result type is the given struct by coercing to that
   * type (useful for constructing tuples with nullable members)
   */
  public Tuple tupleOfType(Type coerceTo, String expression) {
    return (Tuple) coerceTo.coerce(tuple(expression));
  }

  public List<Tuple> tuples(String typedef, String... arrays) {
    Struct struct = realizationContext.normalizeStruct(
        project.getTypeBuilder().build(typedef).find(Struct.class).get());
    return tuples(struct, arrays);
  }

  public List<Tuple> tuples(Struct struct, String... arrays) {
    List<Tuple> tuples = new ArrayList<>(arrays.length);

    for (String array : arrays) {
      if (!array.startsWith("[")) {
        array = "[" + array + "]";
      }
      List<?> values = (List<?>) expressionRealizer.realize(Struct.EMPTY_STRUCT, array).get()
          .evaluate(Tuple.EMPTY_TUPLE);
      tuples.add(Tuple.ofValues(struct, values.toArray()));
    }

    return tuples;
  }

  public Struct norm(Struct input) {
    return realizationContext.normalizeStruct(input);
  }

}
