/*
 * 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.FileReader;
import java.io.IOException;
import java.io.UncheckedIOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.Collections;
import java.util.List;
import java.util.ResourceBundle;
import java.util.function.Consumer;

import org.jline.terminal.TerminalBuilder;
import org.slf4j.bridge.SLF4JBridgeHandler;

import com.google.common.base.Strings;
import com.google.common.collect.Lists;
import com.google.common.io.CharStreams;

import lombok.RequiredArgsConstructor;

import nz.org.riskscape.cli.Terminal;
import nz.org.riskscape.engine.BuildInfo;
import nz.org.riskscape.engine.Engine;
import nz.org.riskscape.engine.OsUtils;
import nz.org.riskscape.engine.Project;
import nz.org.riskscape.engine.i18n.Messages;
import nz.org.riskscape.engine.plugin.Plugin;
import nz.org.riskscape.engine.plugin.PluginRepository;
import nz.org.riskscape.engine.problem.GeneralProblems;
import nz.org.riskscape.engine.spi.CliCommand;
import nz.org.riskscape.picocli.CommandLine;
import nz.org.riskscape.picocli.CommandLine.Command;
import nz.org.riskscape.picocli.CommandLine.Help.Ansi;
import nz.org.riskscape.picocli.CommandLine.IVersionProvider;
import nz.org.riskscape.picocli.CommandLine.Model.UsageMessageSpec;
import nz.org.riskscape.picocli.CommandLine.ParseResult;
import nz.org.riskscape.problem.Problem;

import it.geosolutions.jaiext.JAIExt;
import lombok.extern.slf4j.Slf4j;

/**
 * CLI Application entry point.
 */
@Slf4j
@RequiredArgsConstructor
public class Main implements IVersionProvider {

  /**
   * Annoyingly required helper to read plain text files from the base of the project/install dir
   */
  private static String readTextFile(String filename) {
    try {
      File file = lookupApplicationHome().resolve(filename).toFile();

      if (!file.exists() || !file.canRead()) {
        return "";
      }

      return CharStreams.toString(new FileReader(file));
    } catch (IOException e) {
      throw new UncheckedIOException(e);
    }
  }

  public static final String COPYRIGHT_NOTICE = readTextFile("COPYRIGHT");
  public static final String LICENSE_NOTICE = readTextFile("LICENSE");

  public static final String CLI_HELP_RESOURCE_BUNDLE_NAME = "cli-help";



  protected CommandLine cmd;

  private final org.jline.terminal.Terminal jlineTerminal;
  protected final Terminal terminal;
  protected final Messages messages;
  protected final Consumer<Integer> exithook;// = (code) -> System.exit(code);

  protected final PluginRepository pluginRepository;

  protected final CliBootstrap bootstrap;

  /**
   * Java app entry point.
   */
  public static void main(String[] args) {
    // HSQLDB is used by geotools epsq system. If given the chance HSQLDB will reconfigure java.util.logging(JUL)
    // which can cause it's log messages to be output by JUL when run on JDK17.
    // We can stop HSQLDB from doing this JUL reconfig by setting this system property.
    System.setProperty("hsqldb.reconfig_logging", "false");
    SLF4JBridgeHandler.removeHandlersForRootLogger();
    SLF4JBridgeHandler.install();

    // GeoTools comes with the JAI-ext which is a replacement to the old and unsupported JAI.
    // But we need to initialize it to make use of it.
    JAIExt.initJAIEXT();

    Main main = new Main();

    try {
      main.runMain(args);
    } catch (Exception ex) {
      main.unhandledException(ex, showStackTrace(args));
    }
    try {
      // the jline terminal should be closed to restore it's original state but Closeable.close() may
      // throw an IOException.
      main.jlineTerminal.close();
    } catch (IOException e) {
      // Since we're in the process of shutting down the terminal there's every chance that handling the error with
      // main.unhandledException won't play nice (as that uses the err stream from the jline terminal).
      System.err.format("RiskScape could not shutdown properly due to an unexpected error%n");
      e.printStackTrace(System.err);
    }

  }

  public Main() {
    this.bootstrap = CliBootstrap.getInstance();
    this.jlineTerminal = buildJlineTerminal();

    this.messages = bootstrap.getMessages();
    this.terminal = new JlineTerminal(jlineTerminal, messages);
    this.exithook = (code) -> System.exit(code);
    this.pluginRepository = PluginRepository.INSTANCE;
  }

  /**
   * Standard logic for looking up where RiskScape seems to be installed
   */
  public static Path lookupApplicationHome() {
    String appHome = System.getProperty("riskscape.app_home");
    // NB terminal isn't available yet
    if (Strings.isNullOrEmpty(appHome)) {
      System.err.println("WARNING: riskscape.app_home property is not set, bad things likely to happen");
      appHome = ".";
    }

    Path path = Paths.get(appHome);
    if (!Files.isDirectory(path)) {
      System.err.println(String.format(
          "WARNING: riskscape.app_home '%s' does not exist, bad things likely to happen",
          path));
    }

    return path;
  }


  @Override
  public String[] getVersion() throws Exception {
    List<String> versionInfo = Lists.newArrayList();

    // get the engine version
    Engine engine = bootstrap.buildEngine();
    BuildInfo buildInfo = engine.getBuildInfo();
    String engineVersion = "RiskScape Core Engine v" + buildInfo.getVersion();
    versionInfo.add(engineVersion);
    versionInfo.add(String.join("", Collections.nCopies(engineVersion.length(), "-")));
    versionInfo.add("Build time - " + buildInfo.getBuildTime());
    versionInfo.add("Git SHA1   - " + buildInfo.getCommitInfo());
    versionInfo.add("");

    // add the loaded plugin versions
    versionInfo.add("Plugins:");
    for (Plugin plugin : engine.getPlugins()) {
      versionInfo.add(String.join("  ",
          String.format("%-11s", plugin.getId()),
          plugin.getDescriptor().getVersion(),
          plugin.getDescriptor().getPluginClassName()));
    }
    versionInfo.add("");

    versionInfo.add("System:");
    versionInfo.add(String.join(" ", System.getProperty("os.name"), System.getProperty("os.version")));
    versionInfo.add(String.join(" ", "Java", System.getProperty("java.version"), System.getProperty("java.vm.name"),
        System.getProperty("java.vm.version")));

    return versionInfo.toArray(new String[versionInfo.size()]);
  }

  public void runMain(String[] args) throws Exception {

    try {
      // not applicable for tests
      if (terminal instanceof BaseTerminal) {
        ((BaseTerminal)terminal).showStackTrace = showStackTrace(args);
      }
      bootstrap.setLogLevel(args);
      bootstrap.setApplicationHome(lookupApplicationHome());
      bootstrap.setPluginRepository(pluginRepository);
      bootstrap.setTerminal(terminal);

      log.info("RiskScape engine started with {} Mb max memory", Runtime.getRuntime().maxMemory() / 1024 / 1024);

      CliRoot cliRootFirstPass = doInitialParse(args);
      if (cliRootFirstPass == null) {
        processResult(new ExitCode(1));
        return;
      }

      if (cliRootFirstPass.licenseRequested) {
        showLicense();
        return;
      }

      bootstrap.setRootOptions(cliRootFirstPass);
      bootstrap.initializePlugins();

      OsUtils.checkConsoleCompatibility();

      Object commandOrExitCode = doSecondParse(args);

      AutoCloseable closeable;
      if (!(commandOrExitCode instanceof AutoCloseable)) {
        closeable = null;
      } else {
        closeable = (AutoCloseable) commandOrExitCode;
        // we register a shutdown hook to close up as a fail safe. This will capture some non-success
        // cases like the user ctrl-c during command execution
        Runtime.getRuntime().addShutdownHook(new Thread() {
          @Override
          public void run() {
            try {
              closeable.close();
            } catch (Exception e) {
              // hopefully we won't get an exception here. we're already shutting down so
              // who knows what state the terminal is in. so let's just rethrow in a RuntimeExcepion
              // and if it happens, hope someone tells us
              throw new RuntimeException(e);
            }
          }
        });
      }

      Object result;
      if (commandOrExitCode instanceof ExitCode) {
        result = commandOrExitCode;
      } else {
        result = runCommand(commandOrExitCode);
      }
      processResult(result);

      if (closeable != null) {
        closeable.close();
      }
    } catch (ExitException ex) {
      handleExitException(ex, showStackTrace(args));
    }
  }

  private void showLicense() {
    terminal.getOut().println(LICENSE_NOTICE);
    return;
  }

  private Object runCommand(Object command) throws Exception {
    if (command instanceof TerminalCommand) {
      ((TerminalCommand) command).setTerminal(terminal);
      ((TerminalCommand) command).setMessages(messages);

    }

    if (command instanceof EngineCommand) {
      //Initialize the enginge
      Engine engine = bootstrap.buildEngine();
      EngineCommand engineCommand = (EngineCommand) command;
      engineCommand.setEngine(engine);
    }
    if (command instanceof ApplicationCommand) {
      //now populate it with project resources
      Project project = bootstrap.buildProject();
      ApplicationCommand projectCommand = (ApplicationCommand)command;
      projectCommand.setProject(project);
    }

    if (command instanceof ChildCommand) {
      return ((ChildCommand) command).run();
    } else {
      throw new RuntimeException("Bad command object - " + command + " must implement ChildCommand to be executed");
    }
  }


  /**
   * Parses arguments for a second time. To be called after engine/project has be initialized with any
   * args from first parse.
   *
   * @param args to parse
   * @return {@link CommandLine} or an {@link ExitCode} if no command is able to be run
   */
  private Object doSecondParse(String[] args) {
    CommandLine cl = newCommandLine();

    addSubcommands(cl);

    //Apply default settings to command hierarchy.
    cl = applyDefaults(cl);

    ParseResult parsed = cl.parseWithHandler(r -> r, args);

    if (parsed == null) {
      // parsing failed, an error will have been dumped to stderr already, bail with an error code
      return new ExitCode(1);
    }

    List<CommandLine> asCommandLineList = parsed.asCommandLineList();
    CommandLine lastCommandLine = asCommandLineList.get(asCommandLineList.size() - 1);

    if (CommandLine.printHelpIfRequested(asCommandLineList, terminal.getOut(), terminal.getErr(), Ansi.AUTO)) {
      return new ExitCode(0);
    }

    if (lastCommandLine.getCommand() instanceof StubCommand) {
      terminal.getErr().println("Error: No subcommand argument given");
      CommandLine.usage(lastCommandLine, terminal.getErr(), Ansi.AUTO);

      return new ExitCode(1);
    }

    if (asCommandLineList.size() > 2
        && asCommandLineList.get(1).getCommand() instanceof BetaCommand
        && asCommandLineList.get(2).getCommand() instanceof nz.org.riskscape.engine.cli.model.ModelCommand) {

        terminal.printProblems(GeneralProblems.get()
            .deprecated("`riskscape beta model ...`", "`riskscape model ...`"));
    }

    return lastCommandLine.getCommand();
  }

  private void addSubcommands(CommandLine cl) {
    addSubcommand(cl, new BookmarksCommand());
    addSubcommand(cl, new FunctionCommand());
    addSubcommand(cl, new nz.org.riskscape.engine.cli.model.ModelCommand());
    addSubcommand(cl, new BetaCommand());
    addSubcommand(cl, new TypesCommand());
    addSubcommand(cl, new TypeRegistryCommand());
    addSubcommand(cl, new ResourceCommand());
    addSubcommand(cl, new PipelineCommand());
    addSubcommand(cl, new ExpressionCommand());
    addSubcommand(cl, new I18nCommand());
    addSubcommand(cl, new FormatsCommand());
    addSubcommand(cl, new DocsCommand());

    for (CliCommand cliCommand : bootstrap.getExtensionPoints().getFeaturesOfType(CliCommand.class)) {
      addSubcommand(cl, cliCommand.newInstance());
    }
  }

  private void addSubcommand(CommandLine cl, Object command) {
    String name = command.getClass().getAnnotation(Command.class).name();
    CommandLine existing = cl.getSubcommands().get(name);
    if (existing != null) {
      terminal.getErr().format(
        "WARNING: Ignoring command '%s' named '%s' - already added by '%s'%n",
        command,
        name,
        existing.getCommand()
        );
    } else {
      cl.addSubcommand(name, command);
    }
  }

  private CommandLine newCommandLine() {
    return new CommandLine(new CliRoot());
  }

  /**
   * Applies default settings to the {@link CommandLine} then returns it.
   *
   * Should be called after all sub-commands have been added to ensure setting are propagated through
   * the command hierarchy.
   *
   * @param cl command line to add default settings to
   * @return command line with settings applied
   */
  private CommandLine applyDefaults(CommandLine cl) {
    //Now that sub commands have been populated we can change some settings, and those setting will
    //propagate to the sub commands.
    ResourceBundle cliHelpResourceBundle =
      bootstrap.getMessages().getResourceBundle(CLI_HELP_RESOURCE_BUNDLE_NAME, terminal.getLocale());

    cl.setResourceBundle(cliHelpResourceBundle);
    cl.setCaseInsensitiveEnumValuesAllowed(true);

    cl.getHelpSectionMap().put(UsageMessageSpec.SECTION_KEY_FOOTER, help ->
        OsUtils.LINE_SEPARATOR + COPYRIGHT_NOTICE);

    return cl;
  }

  private CliRoot doInitialParse(String[] args) {
    CommandLine cl = applyDefaults(newCommandLine());

    cl.setStopAtUnmatched(true);

    ParseResult parsed = cl.parseWithHandler(p -> p, args);

    if (parsed == null) {
      return null;
    }

    return parsed.asCommandLineList().get(0).getCommand();
  }

  private static boolean showStackTrace(String[] args) {
    for (String arg : args) {
      if ("--show-stacktrace".equals(arg) || "-e".equals(arg)) {
        return true;
      }
    }
    return false;
  }

  protected void handleExitException(ExitException ex, boolean showStackTrace) {
    ex.toExitCode(messages).action(terminal.getErr(), () -> {
      if (ex.getProblem() == ExitException.NO_PROBLEM) {
        // if there is no problem then we don't need to do anything here.
        // this will happen when an exception is thrown because the user wants to exit RiskScape.
        return;
      } else if (ex instanceof InvalidUsageException) {
        Object command = ((InvalidUsageException) ex).getCommand();
        CommandLine cl = new CommandLine(command);
        cl.usage(terminal.getErr());
      } else if (showStackTrace) {
        // we do some special case logic here to use the cause's print stack trace method, rather than exit exception's,
        // primarily to allow ExecutionException's special print stack trace method to be used - arguably, an exit
        // exception's stack trace isn't super helpful, as it's supposed to be used as a glorified long jump to
        // efficiently (code-wise) short circuit command execution and dump the user back to a prompt with an error
        // message
        if (ex.getCause() != null) {
          StackTraceElement[] stack = ex.getStackTrace();
          if (stack != null && stack.length > 0) {
            terminal.getErr().println("Exit exception thrown from " + stack[0].toString());
          }
          terminal.getErr().println("Caused by:");
          ex.getCause().printStackTrace(terminal.getErr());
        } else {
          ex.printStackTrace(terminal.getErr());
        }

      }
    }, exithook);
  }

  protected void unhandledException(Exception ex, boolean showStackTrace) {
    String messageStart = "There was an unexpected error while running Riskscape. ";

    String message;
    if (showStackTrace) {
      message = messageStart + "Detailed error information follows. Please include this information with a bug "
          + "report, as well as the command you were running and any relevant model data.";
    } else {
      message = messageStart + "Set --show-stacktrace to show detailed error information.";
    }

    terminal.getErr().println(message);
    if (showStackTrace) {
      terminal.getErr().println(messages.renderProblem(ex).toString());
      ex.printStackTrace(terminal.getErr());
    }
    exithook.accept(1);
  }

  public void processResult(Object result) {
    if (result != null) {
      if (result instanceof ExitCode) {
        ExitCode exitCode = (ExitCode) result;
        exitCode.action(terminal.getErr(), null, exithook);
      } else if (result instanceof String) {
        terminal.getOut().println(result);
      } else if (result instanceof Problem) {
        terminal.getErr().println(messages.renderProblem((Problem) result));
        exithook.accept(1);
      } else if (result instanceof Table) {
        ((Table) result).print(terminal);
      } else if (result != null) {
        throw new RuntimeException("unhandled return object: " + result);
      }
    }
  }

  private org.jline.terminal.Terminal buildJlineTerminal() {
    try {
      // Return the system terminal
      return TerminalBuilder.builder()
                          .system(true)
                          .build();
    } catch (IOException e) {
      // TODO, consider if falling back to a non system terminal is worthwhile here.
      System.err.format("RiskScape could not start due to an unexpected error: %s%n", e.getMessage());
      e.printStackTrace(System.err);
      System.exit(-1);
      return null;
    }
  }

}
