/*
 * 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 java.io.IOException;
import java.net.URI;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.Collections;
import java.util.LinkedList;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.function.Consumer;
import java.util.stream.Collectors;
import java.util.stream.Stream;

import com.google.common.collect.Streams;

import lombok.Getter;
import lombok.Setter;
import lombok.extern.slf4j.Slf4j;
import nz.org.riskscape.config.Config;
import nz.org.riskscape.config.ini.IniConfig;
import nz.org.riskscape.config.ini4j.Ini4jConfig;
import nz.org.riskscape.engine.bind.ParameterBinder;
import nz.org.riskscape.engine.data.Bookmark;
import nz.org.riskscape.engine.data.BookmarkResolver;
import nz.org.riskscape.engine.data.DefaultBookmarkResolvers;
import nz.org.riskscape.engine.filter.FilterFactory;
import nz.org.riskscape.engine.function.FunctionFramework;
import nz.org.riskscape.engine.i18n.Messages;
import nz.org.riskscape.engine.io.DiskStorage;
import nz.org.riskscape.engine.io.TupleStorage;
import nz.org.riskscape.engine.model.Model;
import nz.org.riskscape.engine.model.ModelFramework;
import nz.org.riskscape.engine.output.DefaultPipelineOutputStores;
import nz.org.riskscape.engine.output.Format;
import nz.org.riskscape.engine.output.PipelineOutputStores;
import nz.org.riskscape.engine.pipeline.PipelineExecutor;
import nz.org.riskscape.engine.pipeline.PipelineSteps;
import nz.org.riskscape.engine.plugin.ExtensionPoints;
import nz.org.riskscape.engine.plugin.Plugin;
import nz.org.riskscape.engine.plugin.PluginFeature;
import nz.org.riskscape.engine.resource.DefaultResourceFactory;
import nz.org.riskscape.engine.resource.ResourceFactory;
import nz.org.riskscape.engine.sched.SchedulerBasedExecutor;
import nz.org.riskscape.engine.types.TypeRegistry;
import nz.org.riskscape.engine.util.FileUtils;
import nz.org.riskscape.problem.Problem;
import nz.org.riskscape.problem.ProblemSink;
import nz.org.riskscape.problem.ResultOrProblems;

@Slf4j
public class DefaultEngine extends DefaultIdentifiedLocator implements Engine.Writeable {

  /**
   * Well-known name of the beta plugin
   */
  public static final String BETA_PLUGIN_ID = "beta";

  private static Path getFallbackHomeDir() {
    log.warn("No home directory given, falling back to pwd - this should not happen outside of test environments");
    return Paths.get(".");
  }

  /**
   * This is a bit of a kludge to allow test cases to register other features outside of the scope of plugins
   */
  private final List<Object> manuallyRegisteredFeatures = new LinkedList<>();

  @Getter
  private final DefaultBookmarkResolvers bookmarkResolvers = put(new DefaultBookmarkResolvers());

  @Getter
  private final IdentifiedCollection<ParameterBinder> binders =
      put(new IdentifiedCollection.Base<>(ParameterBinder.class));

  @Getter
  private final IdentifiedCollection<Format> formats =
      put(new IdentifiedCollection.Base<>(Format.class));

  private FilterFactory filterFactory = new FilterFactory();

  @Getter @Setter
  private Messages messages;

  @Getter
  private final DefaultDiagnostics diagnostics = new DefaultDiagnostics();

  @Getter
  private MemoryMonitoring memoryPressureGauge = createMemoryPressureGauge();

  @Getter
  private TupleStorage tupleStorage = new DiskStorage(diagnostics.counter("disk-storage"));

  @Getter
  private ProblemSink problemSink = ProblemSink.DEVNULL;

  @Getter @Setter
  private ResourceFactory resourceFactory = new DefaultResourceFactory();

  @Getter
  private final PipelineSteps pipelineSteps = put(new PipelineSteps());

  @Getter
  private final TypeRegistry typeRegistry = put(new TypeRegistry());

  @Getter @Setter
  private PipelineExecutor pipelineExecutor = new SchedulerBasedExecutor(this);

  // in the mean time, always use the DefaultPipelineExecutor for
  // child pipelines. There's more work to support running child
  // pipelines in a ScheduleBasedExecutor, but child pipelines may
  // just disappear if we can rework the join-loop step
  @Getter
  private final PipelineExecutor childPipelineExecutor = pipelineExecutor;

  @Getter
  private final BuildInfo buildInfo;

  @Getter
  private final List<Plugin> plugins;

  @Getter
  private final ExtensionPoints extensionPoints;

  private final Map<String, List<String>> settings;

  @Getter
  private final Path userHomeDirectory;

  @Getter
  private final Path tempDirectory;

  // default no-plugin constructor - only really to be used by tests, and it might go away if we change the way an
  // engine is constructed (to be more centralized)
  public DefaultEngine() {
      this(
          BuildInfo.UNKNOWN, Collections.emptyList(), new ExtensionPoints(), Collections.emptyMap()
      );

  }

  public DefaultEngine(BuildInfo buildInfo, List<Plugin> plugins, ExtensionPoints extensionPoints,
      Map<String, List<String>> settings) {
    this(buildInfo, plugins, extensionPoints, settings, getFallbackHomeDir());
  }

  public DefaultEngine(BuildInfo buildInfo, List<Plugin> plugins, ExtensionPoints extensionPoints,
      Map<String, List<String>> settings, Path userHomeDirectory) {
    this.buildInfo = buildInfo;
    this.plugins = plugins;
    this.extensionPoints = extensionPoints;
    this.settings = settings;
    this.userHomeDirectory = userHomeDirectory;

    try {
      this.tempDirectory = Files.createTempDirectory("rs-engine-");
    } catch (IOException e) {
      throw new RuntimeException(e);
    }
  }

  @Getter
  private final IdentifiedCollection<ModelFramework> modelFrameworks =
    put(new IdentifiedCollection.Base<>(ModelFramework.class));

  @Getter
  private final PipelineOutputStores pipelineOutputStores =
    put(new DefaultPipelineOutputStores());

  @Getter
  private final IdentifiedCollection<FunctionFramework> functionFrameworks =
    new IdentifiedCollection.Base<>(FunctionFramework.class);

  @Override
  public FilterFactory getFilterFactory() {
    return filterFactory;
  }

  /**
   * Add a {@link ParameterBinder} to the engine for use with converting strings in to various engine objects
   * that are used by {@link Model} and {@link Bookmark}s
   */
  public void add(ParameterBinder binder) {
    this.binders.add(binder);
  }

  public void add(BookmarkResolver bookmarkResolver) {
    bookmarkResolvers.add(bookmarkResolver);
  }

  private MemoryMonitoring createMemoryPressureGauge() {
    MemoryMonitoring gauge = new MemoryMonitoring();
    diagnostics.getMetricRegistry().register("post-gc-heap-pressure", gauge.getPostFullGCHeapFreeLevel());
    diagnostics.getMetricRegistry().register("current-heap-pressure", gauge.getCurrentHeapFreeLevel());
    return gauge;
  }

  @SuppressWarnings({ "rawtypes", "unchecked" })
  @Override
  public <T> List<T> getFeaturesOfType(Class<T> featureClass) {
    // features should be one or the other, but support both from here for convenience.  Once everything is moved
    // across, we can change the type sig here to extend PluginFeature
    if (PluginFeature.class.isAssignableFrom(featureClass)) {
      return getExtensionPoints().getFeaturesOfType((Class) featureClass);
    }

    Stream<T> fromPlugins = plugins.stream()
        .map(p -> p.supportsFeature(featureClass))
        .filter(Optional::isPresent)
        .map(Optional::get);

    Stream<T> manuallyRegistered = manuallyRegisteredFeatures
        .stream()
        .filter(f -> featureClass.isInstance(f))
        .map(f -> featureClass.cast(f));

    return Streams.concat(fromPlugins, manuallyRegistered).collect(Collectors.toList());
  }

  /**
   * Register a 'feature' implementation with the engine.  This object will be returned by any calls to
   * {@link #getFeaturesOfType(Class)} where the registered object implements the given class.  This is mostly here for
   * tests to 'pickle' the engine and due consideration should be given before using this in 'real' code.
   * @param implementation the feature to register.
   */
  public void addFeature(Object implementation) {
    this.manuallyRegisteredFeatures.add(implementation);
  }

  @Override
  public boolean isBetaPluginEnabled() {
    // kludge: Because the beta code is loaded dynamically, the only way to detect whether it is available or not
    // (without trying to do a Class.forName) is this.  This could be avoided if we separated the class loading part
    // of the plugin mechanism from the plugin loading part, but that seems excessive to support this particular case.
    return getPlugins().stream().anyMatch(plugin ->  plugin.getId().equals(BETA_PLUGIN_ID));
  }

  public void setProblemSink(ProblemSink sink) {
    problemSink = sink;
    // setup diagnostics to use the same sink for notifications
    diagnostics.setNotificationSink(sink);
  }

  @Override
  public Project emptyProject() {
    return buildProject(Config.EMPTY, problems -> {}).get();
  }

  @Override
  public ResultOrProblems<Project> buildProject(URI location, Consumer<Problem> problems) {
    ResultOrProblems<? extends Config> loadedConfig;
    if (location == EMPTY_PROJECT_LOCATION) {
      // an empty project should be relative to the current working directory.
      loadedConfig = ResultOrProblems.of(Config.empty(Paths.get("").toUri()));
    } else {
      // off by default
      boolean useLegacyParser = getSetting("engine.use-legacy-ini").equals(List.of("true"));
      if (useLegacyParser) {
        loadedConfig = Ini4jConfig.load(location, getResourceFactory());
      } else {
        loadedConfig = IniConfig.load(location, getResourceFactory());
      }
    }
    return loadedConfig
      .drainWarnings(problems)
      .flatMap((config, iniProblems) -> buildProject(config, problems));
  }

  @Override
  public void close() {
    try {
      if (Files.exists(tempDirectory)) {
        // remove the tempDirectory along with everything in it.
        FileUtils.removeDirectory(tempDirectory);
      }
    } catch (IOException e) {
      log.warn("could not delete engine temporary directory", e);
    }
  }

  private ResultOrProblems<Project> buildProject(Config config, Consumer<Problem> problems) {
    DefaultProject project = new DefaultProject(this, config);
    DefaultProjectBuilder builder = new DefaultProjectBuilder();

    ResultOrProblems<Project> result;
    try {
      result = builder.init(project);
    } catch (ObjectAlreadyExistsException ex) {
      return ResultOrProblems.failed(ex.getProblem());
    }

    result.getProblems().forEach(problems);

    return ResultOrProblems.of(project);
  }

  @Override
  public List<String> getSetting(String settingsKey) {
    return settings.getOrDefault(settingsKey, Collections.emptyList());
  }
}
