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

import java.io.File;
import java.io.FileInputStream;
import java.io.IOException;
import java.net.MalformedURLException;
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.HashSet;
import java.util.LinkedList;
import java.util.List;
import java.util.Map;
import java.util.Map.Entry;
import java.util.Set;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.function.Consumer;
import java.util.stream.Collectors;

import org.ini4j.Ini;
import org.ini4j.Profile;
import org.slf4j.LoggerFactory;

import com.google.common.collect.Iterables;
import com.google.common.collect.Lists;
import com.google.common.collect.Maps;

import ch.qos.logback.classic.Level;
import ch.qos.logback.classic.Logger;
import lombok.Getter;
import lombok.Setter;
import lombok.extern.slf4j.Slf4j;
import nz.org.riskscape.cli.Terminal;
import nz.org.riskscape.engine.BuildInfo;
import nz.org.riskscape.engine.DefaultEngine;
import nz.org.riskscape.engine.Engine;
import nz.org.riskscape.engine.OsUtils;
import nz.org.riskscape.engine.Project;
import nz.org.riskscape.engine.ProjectBuilder;
import nz.org.riskscape.engine.RandomSingleton;
import nz.org.riskscape.engine.RiskscapeException;
import nz.org.riskscape.engine.core.EnginePlugin;
import nz.org.riskscape.engine.i18n.DefaultMessages;
import nz.org.riskscape.engine.i18n.ResourceClassLoader;
import nz.org.riskscape.engine.ini.IniParser;
import nz.org.riskscape.engine.pipeline.ExecutionContext;
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.plugin.PluginRuntimeException;
import nz.org.riskscape.engine.problem.ProblemFactory;
import nz.org.riskscape.engine.problem.SeverityLevel;
import nz.org.riskscape.engine.resource.UriHelper;
import nz.org.riskscape.engine.spi.EngineBootstrapper;
import nz.org.riskscape.engine.spi.EngineCollection;
import nz.org.riskscape.engine.util.Pair;
import nz.org.riskscape.problem.Problem;
import nz.org.riskscape.problem.Problems;
import nz.org.riskscape.problem.ResultOrProblems;


/**
 * Class for controlling engine boostrapping for a CLI process/engine
 */
@Slf4j
public class CliBootstrap {

  private static CliBootstrap instance;

  public static CliBootstrap getInstance() {
    if (instance == null) {
      instance = new CliBootstrap();
    }
    return instance;
  }

  public interface LocalProblems extends ProblemFactory {
    /**
     * When a Project.ini file is not present in the current working directory
     * the riskscape functions will not function
     */
    @SeverityLevel(Problem.Severity.WARNING)
    Problem noProjectFile();
  }

  public static final LocalProblems PROBLEMS = Problems.get(LocalProblems.class);

  /**
   * State machine (ahem) for cli bootrapping
   */
  public enum State {
    NOTHING(),

    /**
     * Application home has been set - we know where all our files should be
     */
    APPLICATION_HOME_SET(NOTHING),

    /**
     * A terminal is available for output
     */
    TERMINAL_SET(APPLICATION_HOME_SET),

    /**
     * The main initialization options have been parsed
     */
    ROOT_OPTIONS_SET(TERMINAL_SET),
    /**
     * The plugins have been loaded and started
     */
    PLUGINS_ACTIVATED(ROOT_OPTIONS_SET),
    /**
     * An instance of an engine has been built and initialized
     */
    ENGINE_BUILT(PLUGINS_ACTIVATED),
    /**
     * The project(types, functions etc) has been built.
     */
    PROJECT_BUILT(ENGINE_BUILT);

    @Getter
    private final State[] allowedFrom;

    State() {
      this.allowedFrom = new State[0];
    }

    State(State... rest) {
      this.allowedFrom = rest;
    }

    /**
     * @return true if the state transition is allowed from this state to the given next state
     */
    public boolean isAllowedFrom(State nextState) {
      for (int i = 0; i < allowedFrom.length; i++) {
        if (allowedFrom[i] == nextState) {
          return true;
        }
      }
      return false;
    }
  }

  @Getter
  private ProjectBuilder builder;

  @Getter
  private State state = State.NOTHING;

  @Getter
  private Terminal terminal;

  @Getter
  private CliRoot cliRoot;

  @Getter
  private DefaultEngine engine;

  @Getter
  private Project project;

  @Getter
  private Map<String, List<String>> settings;

  @Getter @Setter
  private PluginRepository pluginRepository;

  @Getter
  private ExtensionPoints extensionPoints = new ExtensionPoints();

  @Getter
  private DefaultMessages messages = new DefaultMessages(extensionPoints);

  @Getter
  private AppLayout layout;

  protected void changeState(State nextState, Runnable callback) {
    if (nextState.isAllowedFrom(state)) {
      callback.run();
      this.state = nextState;
    } else {
      throw new RiskscapeException(String.format(
          "Application did not start properly... can not move from '%s' to '%s'",
          state,
          nextState));
    }
  }

  public void setApplicationHome(Path appHome) {
    changeState(State.APPLICATION_HOME_SET, () -> {
      // TODO validate layout at this point
      this.layout = new AppLayout(appHome);
    });
  }

  /**
   * @return a directory expected to contain local application settings and cache files, etc.  This used to be a place
   * where you could put project resources, but that is now `--project`
   */
  Path getUserRiskScapeHome() {
    return cliRoot.getHomeDir().map(v -> Paths.get(v)).orElseGet(() ->
      getDefaultUserRiskScapeHome()
    );
  }

  Path getDefaultUserRiskScapeHome() {
    // https://specifications.freedesktop.org/basedir-spec/basedir-spec-latest.html
    // Note that Mac and Windows both default to ~/RiskScape. Our users are happy enough
    // with this locations, whereas other system app-data directories (i.e. %AppData% on Windows
    // and ~/Library/Application Support on Mac), tend to be hidden and therefore harder to find
    return Paths.get(System.getProperty("user.home")).resolve(OsUtils.isLinux() ? ".config/riskscape" : "RiskScape");
  }

  public void initializePlugins() {
    changeState(State.PLUGINS_ACTIVATED, () -> {

      pluginRepository.register(EnginePlugin.DESCRIPTOR);
      pluginRepository.register(CliPlugin.DESCRIPTOR);

      // options on cli take precedence
      for (Path plugin : cliRoot.getPluginPaths()) {
        if (Files.exists(plugin)) {
          pluginRepository.addPath(plugin.toFile());
        } else {
          throw new ExitException(1, "Plugin '%s' does not exist", plugin);
        }
      }

      // map the load-plugin values to paths relative to the settings file. (getUserRiskScapeHome() is
      // where the settings file is located)
      List<Path> pluginPaths = settings.getOrDefault("global.load-plugin", List.of())
          .stream()
          .map(path -> toPluginPath(path))
          .collect(Collectors.toList());

      if (cliRoot.getBetaFeaturesEnabled() == Boolean.TRUE) {
        Path betaPluginPath = toPluginPath(DefaultEngine.BETA_PLUGIN_ID);
        // avoid a warning if the user has added the beta plugin via load-plugin
        if (!pluginPaths.contains(betaPluginPath)) {
          pluginPaths.add(betaPluginPath);
        }
      } else if (cliRoot.getBetaFeaturesEnabled() == Boolean.FALSE) {
        // doesn't matter where it came from on the filesystem, if it matches the name 'beta', it's toast
        pluginPaths.removeIf(path -> path.getFileName().toString().equals(DefaultEngine.BETA_PLUGIN_ID));
      }

      for (Path plugin : pluginPaths) {
        if (Files.exists(plugin)) {
          pluginRepository.addPath(plugin.toFile());
        } else {
          throw new ExitException(1, "Plugin '%s' does not exist", plugin);
        }
      }

      // work out if the core plugins should be skipped
      boolean skipCorePlugins = false;
      if (settings.containsKey("global.no-core-plugins")) {
        skipCorePlugins = Boolean.valueOf(settings.get("global.no-core-plugins").get(0));
      }
      skipCorePlugins = skipCorePlugins | cliRoot.isDisableLoadingCorePlugins();

      // add core plugins, not if they should be skipped though
      if (!skipCorePlugins) {
        Path corePlugins = layout.getPluginsDir();
        if (Files.isDirectory(corePlugins)) {
          pluginRepository.scanDirectory(corePlugins);
        } else {
          terminal.getErr().format(
            "WARN: Application home '%s' does not include a plugins directory%n",
            getLayout().getRoot()
          );
        }
      }

      startPlugins();

      pluginRepository.collectFeatures(this.extensionPoints);
    });
  }

  /**
   * Converts plugin to a path. The plugin is first looked for in APP_HOME/plugins, then APP_HOME/plugins-optional.
   * If the plugin is found in either of these locations then that path is returned. Else the plugin is resolved
   * against the user home directory.
   *
   * @param plugin the name or path to the required plugin
   * @return path to the plugin
   */
  private Path toPluginPath(String plugin) {
    Path pluginPath = layout.getPluginsDir().resolve(plugin);

    Path resolved;
    if (Files.exists(pluginPath)) {
      resolved = pluginPath;
    } else {
      pluginPath = layout.getPluginsOptionalDir().resolve(plugin);
      if (Files.exists(pluginPath)) {
        resolved = pluginPath;
      } else {
        resolved = getUserRiskScapeHome().resolve(plugin);
      }
    }

    return resolved.normalize();
  }

  // package scoped for testing
  void startPlugins() {
    log.info("Starting plugins ...");
    try {
      pluginRepository.activateAll(plugin -> {
        log.info("  {}...", plugin.getId());
          messages.addPluginResources(plugin);
          plugin.startUp(settings, terminal);
      });
    } catch (PluginRuntimeException e) {
      PluginDescriptor pd = e.getPlugin();
      if (pd == null) {
        throw new ExitException(1, e, "Plugins did not start correctly - %s - try disabling the failing plugin"
            + " and start RiskScape again.", e.getMessage());
      } else {
        throw new ExitException(1, e, "Plugin '%s' did not start - %s - try disabling the failing plugin and start"
            + " RiskScape again.  ", e.getPlugin().getPluginId(), e.getMessage());
      }
    }
  }

  public void setRootOptions(CliRoot root) {
    changeState(State.ROOT_OPTIONS_SET, () -> {
      this.cliRoot = root;

      if (this.cliRoot.getRandomSeed() != null) {
        RandomSingleton.setSeed(this.cliRoot.getRandomSeed());
      }

      this.settings = collectSettings();
    });
  }

  /**
   * Parse the settings.ini file from the riskscape home directory.
   * @return map of settings
   * TODO build a Config object instead, plop it on Engine
   */
  Map<String, List<String>> collectSettings() {
    Path settingsFile = getUserRiskScapeHome().resolve("settings.ini");

    log.info("Looking for settings file - {}", settingsFile);
    if (! settingsFile.toFile().exists()) {
      // add look up for old location, but not if they set -H
      File oldLocation = Paths.get(System.getProperty("user.home"), "riskscape", "settings.ini").toFile();
      if (oldLocation.exists() && !cliRoot.getHomeDir().isPresent()) {
        terminal.log(Problem.warning("Engine settings loaded from default legacy location %s.  Move"
            + " this file to %s to hide this warning", oldLocation, getDefaultUserRiskScapeHome()));
        settingsFile = oldLocation.toPath();
      } else {
        log.info("No settings file found");
        return Collections.emptyMap();
      }
    }

    Ini ini;
    try {
      ini = IniParser.parse(new FileInputStream(settingsFile.toFile()));
    } catch (IOException e) {
      throw new ExitException(Problems.foundWith(settingsFile, Problems.caught(e)), e);
    }

    Map<String, List<String>> rawSettings = Maps.newHashMap();
    for (String sectionName : ini.keySet()) {
      List<Profile.Section> sections = ini.getAll(sectionName);

      for (Profile.Section section : sections) {
        for (String entryName : section.keySet()) {
          List<String> values = section.getAll(entryName);
          final String combinedKey = sectionName + "." +  entryName;

          rawSettings.compute(combinedKey, (k, existing) -> {
            if (existing == null) {
              return values;
            } else {
              return Lists.newArrayList(Iterables.concat(existing, values));
            }
          });
        }
      }
    }
    return rawSettings;
  }

  public void setTerminal(Terminal term) {
    changeState(State.TERMINAL_SET, () -> {
      this.terminal = term;
      initializeI18n();
    });
  }

  /**
   * Builds an {@link Engine} and populates it with any service providers that may be provided by {@link Plugin}s.
   * @return engine as built
   */
  public DefaultEngine buildEngine() {
    // just filter out the settings.ini that the engine cares about
    Map<String, List<String>> engineSettings = settings.entrySet().stream()
        .filter(entry -> entry.getKey().startsWith("engine"))
        .collect(Collectors.toMap(Entry::getKey, Entry::getValue));
    this.engine = new DefaultEngine(BuildInfo.getBuildInfo(), pluginRepository.getActivated(), extensionPoints,
        engineSettings, getUserRiskScapeHome());
    this.engine.setMessages(getMessages());

    changeState(State.ENGINE_BUILT, () -> {
      for (EngineCollection feature : extensionPoints.getFeaturesOfType(EngineCollection.class)) {
        engine.registerCollection(feature.newInstance(engine));
      }

      // set the problem sink before bootstrapping so any problems are shown to the user.
      engine.setProblemSink(terminal);

      EngineBootstrapper.bootstrap(extensionPoints, engine);

      // warn users if they are setting experimental execution
      if (cliRoot.isExperimentalExecution()) {
        log.warn("Ignoring --experimental-execution - this option is now deprecated and will disappear in a "
            + "future version");
      }

      if (cliRoot.getPipelineThreads() != null) {
        engine.getPipelineExecutor().setNumThreads(cliRoot.getPipelineThreads());
      }

      for (Plugin plugin : engine.getPlugins()) {
        try {
          plugin.initializeEngine(engine, terminal);
        } catch (Throwable ex) {
          terminal.getErr().format("There were problems during engine initialization:%n%n");
          terminal.getErr().format(
              "Failed to initialize engine with plugin %s, Cause: %s%n",
              plugin.getDescriptor(),
              messages.renderProblem(ex)
          );
        }
      }
    });
    return engine;
  }

  /**
   * Populates project resources (type, functions, bookmarks etc).
   */
  public Project buildProject() {
    changeState(State.PROJECT_BUILT, () -> {

      AtomicInteger errorCount = new AtomicInteger();
      Set<Problem> seen = new HashSet<>();

      Consumer<Problem> problemConsumer = p -> {
        boolean notYetSeen = seen.add(p);
        if (!notYetSeen) {
          // this is a dupe, skip it - this can happen during validation because the way projects have been
          // bootstrapped has changed over the months and years
          return;
        }

        int count = errorCount.incrementAndGet();
        if (cliRoot.isShowProjectErrors()) {
          if (count == 1) {
            terminal.getErr().format("There were problems during engine initialization:%n");
          }
          terminal.printProblems(p);
        }
      };

      URI projectLocation = getProjectConfigLocation();

      ResultOrProblems<Project> built = this.engine.buildProject(projectLocation, problemConsumer);

      project = built
          .drainWarnings(problemConsumer)
          .orElseThrow(ps -> new ExitException(Problems.foundWith("--project", ps)));

      // user has asked us to show project errors, so lets flush out as many errors as possible.
      // Validation could cause a repetition of the same errors, so check for dupes
      if (cliRoot.isShowProjectErrors()) {
        try (ExecutionContext ctx = project.newExecutionContext()) {
          project.validate(ctx, problemConsumer);
        }
      }

      if (errorCount.get() > 0) {
        // inform the user that we're hiding some errors from them
        if (!cliRoot.isShowProjectErrors()) {

            terminal.getErr().format("There were problems during engine initialization, run riskscape with "
            + "--show-project-errors for more details%n", errorCount.get());
        } else {
          // add an extra bit of whitespace to provide some relief between the problems and the command's output
          terminal.getErr().format("%n");
        }
      }
    });

    return project;
  }


  /**
   * Build project configuration from the cli options
   */
  URI getProjectConfigLocation() {
    String projectIni = cliRoot.getProjectIni().orElseGet(() -> {
      // if nothing specified, see if there's one on pwd
      File pwdProjectIni = new File("project.ini");
      if (pwdProjectIni.exists()) {
        return pwdProjectIni.getAbsolutePath();
      } else {
        terminal.log(PROBLEMS.noProjectFile());
        return null;
      }
    });

    if (projectIni == null) {
      return Engine.EMPTY_PROJECT_LOCATION;
    } else {
      return UriHelper.uriFromLocation(projectIni, Paths.get(".").toUri())
          .orElseThrow(ps -> new ExitException(1, Problems.foundWith("--project", ps)));
    }
  }

  private void initializeI18n() {
    Path externalBundlePath = layout.getI18nDir();
    if (Files.isDirectory(externalBundlePath)) {
      ResourceClassLoader forI18nDir;
      try {
        //ResourceClassLoader is used as it will only return resources from the URLs(eg no parent delegation).
        //This is essential to be able to override the files that exist in RiskScape.
        forI18nDir = new ResourceClassLoader("", externalBundlePath.toFile().toURI().toURL());
      } catch (MalformedURLException e) {
        terminal.getErr().println("Could not open ");
        return;
      }
      messages.getClassLoader().append(Main.class, forI18nDir);
    } else {
      log.error("No core i18n resources found!");
    }
    // add api.jar messages after external i18n/ dir, so it has lower precedence
    addApiI18nToMessages();
  }

  /**
   * We need a special case for loading any default messages defined in the api's jar.  The api code is not loaded as
   * a plugin so we need a special case
   */
  public void addApiI18nToMessages() {
    Path apiJar = layout.getLibDir().resolve("api.jar");
    ResourceClassLoader apiLoader;
    try {
      if (!Files.isReadable(apiJar)) {
        terminal.getErr().println("Cannot find API i18n resources at " + apiJar);
        return;
      }
      apiLoader = new ResourceClassLoader(apiJar.toUri().toURL());
    } catch (MalformedURLException e) {
      terminal.getErr().println("Failed to open API i18n resources!");
      return;
    }
    messages.getClassLoader().append(Engine.class, apiLoader);
  }

  /**
   * Scans a list of command line args looking for `--log-level=foo` or `--log-level foo`
   */
  public void setLogLevel(String[] args) {
    parseLogLevelFromArgs(args, terminal).forEach(pair -> {
      pair.getLeft().setLevel(pair.getRight());
    });
  }

  /**
   * Available as a separate static method for unit testing
   */
  public static List<Pair<Logger, Level>> parseLogLevelFromArgs(String[] args, Terminal terminal) {
    String logLevel = null;
    int idx = 0;
    for (String arg : args) {
      if (arg.startsWith("--log-level")) {
        String[] parts = arg.split("=", 2);
        if (parts.length > 1) {
          logLevel = parts[1];
        } else {
          if (args.length > idx + 1) {
            logLevel = args[idx+1];
          }
        }
      }
      idx++;
    }

    if (logLevel != null) {
      LinkedList<Pair<Logger, Level>> parsed = new LinkedList<>();
      String[] levels = logLevel.split(",");

      for (String string : levels) {
        if (string.contains("=")) {
          String[] nameAndLevel = string.split("=", 2);
          String name = nameAndLevel[0];
          Level level = Level.toLevel(nameAndLevel[1]);

          if (name.startsWith(".")) {
            name = "nz.org.riskscape" + name;
          }

          try {
            Logger logger = (Logger) LoggerFactory.getLogger(name);
            parsed.add(Pair.of(logger, level));
          } catch (ClassCastException ex) {
            terminal.getErr().format("Logging configuration error, can not set log level");
            return Collections.emptyList();
          }

        } else {
          Logger logger;
          try {
            String[] loggers = new String[] {Logger.ROOT_LOGGER_NAME, "nz.org.riskscape"};
            for (int i = 0; i < loggers.length; i++) {
              String loggerName = loggers[i];
              logger = (Logger) LoggerFactory.getLogger(loggerName);
              parsed.add(Pair.of(logger, Level.toLevel(string)));
            }
          } catch (ClassCastException ex) {
            terminal.getErr().format("Logging configuration error, can not set log level");
            return Collections.emptyList();
          }
        }
      }

      return parsed;
    } else {
      return Collections.emptyList();
    }


  }
}
