/*
 * 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.test;

import java.io.IOException;
import java.net.MalformedURLException;
import java.net.URL;
import java.nio.charset.Charset;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.Enumeration;
import java.util.HashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Optional;
import java.util.jar.Manifest;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import java.util.stream.Collectors;

import org.junit.runners.BlockJUnit4ClassRunner;
import org.junit.runners.model.FrameworkMethod;
import org.junit.runners.model.InitializationError;
import org.junit.runners.model.Statement;

import com.google.common.io.Files;
import com.google.common.io.LineProcessor;

import lombok.Data;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import nz.org.riskscape.engine.BuildInfo;
import nz.org.riskscape.engine.DefaultEngine;
import nz.org.riskscape.engine.RiskscapeException;
import nz.org.riskscape.engine.cli.CliPlugin;
import nz.org.riskscape.engine.core.EnginePlugin;
import nz.org.riskscape.engine.i18n.DefaultMessages;
import nz.org.riskscape.engine.i18n.Messages;
import nz.org.riskscape.engine.i18n.ResourceClassLoader;
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.PluginDescriptor;
import nz.org.riskscape.engine.plugin.PluginRepository;
import nz.org.riskscape.engine.spi.EngineBootstrapper;
import nz.org.riskscape.problem.Problems;
import nz.org.riskscape.test.TestUtils;
import nz.org.riskscape.util.ListUtils;

/**
 * Integration test runner that sets up an engine like the cli would to run integration tests without having to
 * start a new JVM and to allow results of the test to be examined as java objects, rather than by parsing outputs.
 *
 * The idea for this runner is that it is a light-weight alternative to firing up a new process or docker image for
 * each test - we can run most of our test cases in-process as we're not interested in testing all the aspects of
 * riskscape start up for each test.  The {@link EngineCommandIntegrationTest} goes further and allows a command
 * to be tested by manipulating a command's java object and asserting the result - we're interested in the result of
 * executing a command given a set of inputs, not the mechanics of CLI parsing and result handling (for every test)
 *
 * By default, tests are run with the `getDefaults` list of plugins with no settings, but this can be customized per
 * test class using the {@link EngineTestPlugins} and {@link EngineTestSettings} annotations
 */
@Slf4j
public class EngineTestRunner extends BlockJUnit4ClassRunner {

  private static List<String> getDefaults() {
    // done in a method, rather than a static constant, to delay any class loading - we don't want the definition of the
    // engine test runner class to try and load plugins that might not be on our classpath
    return Arrays.asList(
      "defaults",
      "jython",
      "wizard"
    );
  }

  @Data
  static class Config {
    final List<EngineTestPluginInfo> plugins;
    final Map<String, List<String>> settings;
  }

  /**
   * One config, one large engine and a list of plugins, for only $6.99!
   */
  @RequiredArgsConstructor
  static class EngineCombo {
    final Config config;
    final DefaultEngine engine;
    final List<Plugin> plugins;
    final Messages mesages;
    final ProblemSinkProxy problemSinkProxy;
  }

  private static Map<Config, EngineCombo> engines = new HashMap<>();

  public EngineTestRunner(Class<?> klass) throws InitializationError {
    super(klass);
  }

  public EngineCombo getEngineCombo() throws IOException {
    EngineTestPlugins annos = this.getTestClass().getAnnotation(EngineTestPlugins.class);
    List<String> pluginIds = annos == null ? getDefaults() : Arrays.asList(annos.value());

    List<EngineTestPluginInfo> builtInfos = new ArrayList<>(pluginIds.size());
    Path pluginsRoot = TestUtils.getRootProjectPath().map(p -> p.resolve("plugin")).orElse(null);

    if (pluginsRoot == null) {
      populatePluginsFromJars(pluginIds, builtInfos);
    } else {
      populatePluginsFromSourceTree(pluginIds, pluginsRoot, builtInfos);
    }

    Map<String, List<String>> settings = buildSettings();
    Config config = new Config(builtInfos, settings);

    // this test needs a new engine instance
    if (this.getTestClass().getAnnotation(DirtiesEngine.class) != null) {
      return buildEngine(config);
    }

    return engines.computeIfAbsent(config, this::buildEngine);
  }

  public void populatePluginsFromSourceTree(
    List<String> pluginIds,
    Path pluginsRoot,
    List<EngineTestPluginInfo> builtInfos
  ) throws IOException {

    for (String pluginId : pluginIds) {
      Path pluginPath = pluginsRoot.resolve(pluginId);

      if (!pluginPath.toFile().exists()) {
        throw new RuntimeException("No plugin dir exists at " + pluginPath);
      }

      Path gradleDir = pluginPath.resolve("build.gradle");
      if (!gradleDir.toFile().exists()) {
        throw new RuntimeException("No build.gradle exists at " + gradleDir
            + " - can not build plugin info for " + pluginId);
      }

      String pluginClassName = readPluginClassName(gradleDir);
      builtInfos.add(new EngineTestPluginInfo(pluginId, pluginClassName, Optional.of(pluginPath)));
    }
  }

  public void populatePluginsFromJars(
      List<String> pluginIds,
      List<EngineTestPluginInfo> builtInfos
  ) throws IOException {
    Enumeration<URL> resources = getClass().getClassLoader().getResources("META-INF/MANIFEST.MF");
    while (resources.hasMoreElements()) {
      URL manifestURL = resources.nextElement();
      Manifest mf = new Manifest(manifestURL.openStream());
      String pluginClassName = mf.getMainAttributes().getValue("Riskscape-Plugin");
      if (pluginClassName == null) {
        continue;
      }

      String pluginId = mf.getMainAttributes().getValue("Riskscape-Plugin-ID");

      if (!pluginIds.contains(pluginId)) {
        continue;
      }


      builtInfos.add(new EngineTestPluginInfo(pluginId, pluginClassName, Optional.empty()));
    }
  }

  /**
   * Janky routine for grepping out the plugin class name from a plugin's build script
   */
  private String readPluginClassName(Path gradleDir) throws IOException {
    return Files.asCharSource(gradleDir.toFile(), Charset.defaultCharset()).readLines(new LineProcessor<String>() {

      boolean pluginBlockStarted = false;
      private String className;

      @Override
      public boolean processLine(String line) throws IOException {
        line = line.trim();
        if (pluginBlockStarted) {
          if (line.startsWith("implementation =")) {
            //grep out the class name
            Matcher matcher = Pattern.compile("'([^']+)'").matcher(line);
            if (matcher.find()) {
              this.className = matcher.group(1);
            } else {
              throw new RuntimeException("No implementation = 'foo.bar.Class' line exists in " + gradleDir + ".  "
                  + "Make sure the class name is surrounded by single quotes and is on the same line as the "
                  + "implementation bit");
            }
          }
        } else {
          if (line.startsWith("riskscapePlugin")) {
            pluginBlockStarted = true;
          }
        }

        return true;
      }

      @Override
      public String getResult() {
        if (className != null) {
          return className;
        }
        if (!pluginBlockStarted)  {
          throw new RuntimeException("No riskscapePlugin block exists in " + gradleDir);
        } else {
          throw new RuntimeException("No implementation call exists in riskscapePlugin block in " + gradleDir);
        }
      }
    });
  }

  private Map<String, List<String>> buildSettings() {
    EngineTestSettings settings = this.getTestClass().getAnnotation(EngineTestSettings.class);

    if (settings == null) {
      return Collections.emptyMap();
    } else {
      List<String> keyPairs = Arrays.asList(settings.value());

      return keyPairs.stream()
        .map(line -> line.split("=", 2))
        .collect(Collectors.toMap(
            arr -> arr[0],
            arr -> Arrays.asList(arr[1]),
            ListUtils::concat
        ));
    }

  }

  private EngineCombo buildEngine(Config config) {
    log.info("Initializing engine with config {}...", config);

    /*
     * There is some support now forrunning engine tests from outside of the riskscape development environment.  We can
     * now discover all the plugin code by searching for the plugin manifest, but:
     *
     *   - Only core i18n that is added to JARs is getting loaded (the i18n/ dir isn't included in any libraries we
     *     ship)
     *   - I'm fairly sure plugin-supplied I18n doesn't work yet
     *   - We're not isolating plugin code from other plugins via separate classloaders (which could lead to library
     *   conflicts)
     *
     * The first thing can be fixed by moving over to using the annotation-driven i18n defaults stuff.  The last two can
     * probably be fixed by implementing a real PluginDescriptor for the test environment (and avoiding as much as
     * possible loading any test implementation code in the engine's classloader)
     */
    Path root = TestUtils.getRootProjectPath().orElse(null);
    ProblemSinkProxy problemSinkProxy = new ProblemSinkProxy();

// XXX KLUDGE - we don't have an engine setting for this (but we should) - sometimes we need/want to set this for
    // tests, and this allows it
    final int numPipelineThreads = Integer.parseInt(config.getSettings().getOrDefault("pipeline-threads",
        Collections.singletonList("1")).get(0));

    List<Plugin> plugins = buildPlugins(config.getPlugins());

    ExtensionPoints extensionPoints = new ExtensionPoints();
    PluginRepository.collectFeatures(extensionPoints, plugins);

    DefaultMessages messages = new DefaultMessages(extensionPoints);

    // KLUDGE: See comment on TestUtils#getApiClassResourcesURL
    if (TestUtils.getRootProjectPath().isPresent()) {
      messages.getClassLoader().append(this,
          new ResourceClassLoader(TestUtils.getApiClassResourcesURL()));

      try {
        messages.getClassLoader().append(EngineTestRunner.class,
            new ResourceClassLoader("", root.resolve("engine/core/src/main/i18n").toUri().toURL()));
      } catch (MalformedURLException e) {
        throw new RuntimeException(e);
      }
    } else {
      // this can go away once we've moved all of the default resources to be loaded from the class path
      System.err.println("Warning - no core i18n resources available for this test");
    }


    // just filter out the settings.ini that the engine cares about
    Map<String, List<String>> engineSettings = config.settings.entrySet().stream()
        .filter(entry -> entry.getKey().startsWith("engine"))
        .collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue));

    DefaultEngine engine = new DefaultEngine(
        BuildInfo.UNKNOWN,
        plugins,
        extensionPoints,
        engineSettings
    );

    engine.setProblemSink(problemSinkProxy);
    engine.getPipelineExecutor().setNumThreads(numPipelineThreads);
    engine.setMessages(messages);

    for (EngineTestPluginInfo info : config.getPlugins()) {
      info.getProjectPath()
          .map(path -> root.resolve(path).resolve("src/main/resources/i18n"))
          .ifPresent(i18nDir -> {
            try {
              messages.getClassLoader().append(info.getPluginClass(),
                  new ResourceClassLoader("", i18nDir.toUri().toURL()));
            } catch (MalformedURLException e) {
              throw new RuntimeException(e);
            }
          });
      // @Message are processed by the TranslationProcessor and saved into the following path.
      // so if that path exists add it to the message classpath to.
      info.getProjectPath()
          .map(path -> root.resolve(path).resolve("build/classes/java/main/i18n"))
          .ifPresent(i18nDir -> {
            try {
              messages.getClassLoader().append(info.getPluginClass(),
                  new ResourceClassLoader("", i18nDir.toUri().toURL()));
            } catch (MalformedURLException e) {
              throw new RuntimeException(e);
            }
          });
    }

    EngineBootstrapper.bootstrap(extensionPoints, engine);

    for (Plugin plugin : plugins) {
      plugin.startUp(config.settings, problem
          -> System.err.println(messages.renderProblem(problem, Locale.getDefault()).toString()));
      plugin.initializeEngine(engine);
    }


    log.info("...Done!");

    EngineCombo combo = new EngineCombo(config, engine, plugins, messages, problemSinkProxy);

    return combo;
  }

  private List<Plugin> buildPlugins(List<EngineTestPluginInfo> pluginInfos) {
    List<PluginDescriptor> descriptors = new ArrayList<>();

    // these ones are built in - I can't see us needing to disable them, but maybe making them optional would make sense
    // for speed purposes?
    descriptors.add(EnginePlugin.DESCRIPTOR);
    descriptors.add(CliPlugin.DESCRIPTOR);

    TestUtils.getRootProjectPath();

    for (EngineTestPluginInfo pluginInfo : pluginInfos) {
      descriptors.add(new BuiltInPluginDescriptor(
          pluginInfo.getId(),
          BuildInfo.UNKNOWN,
          pluginInfo.getPluginClass(),
          pluginInfo.getProjectPath()
            .flatMap(pp -> TestUtils.getRootProjectPath().map(rp -> rp.resolve(pp))).orElse(null)
      ));
    }

    return descriptors.stream().map(PluginDescriptor::newPluginInstance).collect(Collectors.toList());
  }

  public void initTest(EngineTest test) throws Exception {
    EngineCombo combo = getEngineCombo();

    test.setProblemSinkProxy(combo.problemSinkProxy);
    test.setEngine(combo.engine);
    test.setMessages(combo.mesages);
  }

  @Override
  protected Statement withBefores(FrameworkMethod method, Object target, Statement statement) {
    // we have a custom setup routine we want to run against an EngineTest where we set the
    // various engine dependencies that most tests are going to require - we slip this in so that it's
    // before all the @Before methods are executed
    // because we want our test's before methods to make use of them
    Statement next = super.withBefores(method, target, statement);
    if (target instanceof EngineTest) {
      return new Statement() {

        @Override
        public void evaluate() throws Throwable {
          EngineTest engineTest = (EngineTest) target;
          initTest(engineTest);
          try {
            next.evaluate();
          } catch (RiskscapeException ex) {
            throw new AssertionError("Test threw a RiskScape Exception:"
                + getEngineCombo().mesages.renderProblem(Problems.caught(ex), Locale.getDefault()),
                ex
            );
          }
        }
      };
    } else {
      return next;
    }
  }
}
