/*
 * 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 static nz.org.riskscape.engine.Assert.*;
import static org.hamcrest.Matchers.*;
import static org.hamcrest.Matchers.contains;
import static org.junit.Assert.*;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.*;

import java.net.URI;
import java.net.URL;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.Arrays;
import java.util.Collections;
import java.util.List;
import java.util.function.Consumer;
import java.util.jar.Manifest;

import org.hamcrest.Description;
import org.hamcrest.DiagnosingMatcher;
import org.hamcrest.Matcher;
import org.hamcrest.Matchers;
import org.junit.Before;
import org.junit.Test;
import org.mockito.InOrder;

import ch.qos.logback.classic.Level;
import ch.qos.logback.classic.Logger;
import nz.org.riskscape.engine.Assert;
import nz.org.riskscape.engine.Engine;
import nz.org.riskscape.engine.OsUtils;
import nz.org.riskscape.engine.Project;
import nz.org.riskscape.engine.RandomSingleton;
import nz.org.riskscape.engine.RiskscapeException;
import nz.org.riskscape.engine.TemporaryDirectoryTestHelper;
import nz.org.riskscape.engine.i18n.DefaultMessages;
import nz.org.riskscape.engine.plugin.DefaultPluginDescriptor;
import nz.org.riskscape.engine.plugin.ExtensionPoints;
import nz.org.riskscape.engine.plugin.Plugin;
import nz.org.riskscape.engine.plugin.PluginClassLoader;
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.resource.ResourceProblems;
import nz.org.riskscape.engine.util.Pair;
import nz.org.riskscape.problem.Problems;

public class CliBootstrapTest implements TemporaryDirectoryTestHelper {

  private static final boolean NO_NUKE_SETTINGS = false;
  PluginRepository pluginRepository = mock(PluginRepository.class);
  CliBootstrap subject;
  TestTerminal terminal;
  Path appHome;
  ExtensionPoints extensionPoints;

  @Before
  public void setup() {
    appHome = Paths.get(".");
    subject = new CliBootstrap();
    extensionPoints = subject.getExtensionPoints();
    subject.setPluginRepository(pluginRepository);
    terminal = new TestTerminal(System.err, System.out, System.in, new DefaultMessages(extensionPoints));
  }

  private CliRoot defaultCliRoot() {
    CliRoot options = new CliRoot();
    options.setHomeDir(".");
    options.setDisableLoadingCorePlugins(false);
    options.setLogLevel("INFO");
    options.setPluginPaths(Arrays.asList(Paths.get(".")));
    return options;
  }

  private void commonInitialization(CliRoot options) {
    commonInitialization(options, true);
  }

  /**
   * Goes through the first few state changes for CliBootstrap, where nothing
   * particularly controversial happens
   */
  private void commonInitialization(CliRoot options, boolean nukeSettings) {
    subject.setApplicationHome(appHome);
    subject.setTerminal(terminal);
    assertSame(terminal, subject.getTerminal());

    subject.setRootOptions(options);
    if (nukeSettings) {
      // nuke these - they'll be picking stuff up from the dev/test environment
      subject.getSettings().clear();
    }
    assertSame(options, subject.getCliRoot());

    subject.initializePlugins();
    verify(pluginRepository).activateAll(any());
    verify(pluginRepository).collectFeatures(extensionPoints);
  }

  @Test
  public void canStepThroughInitializationGettingBuildObjectsAsWeGo() throws Exception {
    commonInitialization(defaultCliRoot());
    // there shouldn't be any settings.ini config loaded
    assertTrue(subject.getSettings().isEmpty());

    Plugin mockPlugin = mock(Plugin.class);
    List<Plugin> plugins = Arrays.asList(mockPlugin);
    when(pluginRepository.getActivated()).thenReturn(plugins);

    Engine engine = subject.buildEngine();
    assertEquals("test-build", engine.getBuildInfo().getVersion()); //Sets the version from build.properties
    assertNotNull(engine);
    subject.buildProject();
  }

  @Test
  public void callingOutOfOrderInitMethodsThrowsAnException() {
    RiskscapeException ex = Assert.assertThrows(RiskscapeException.class, () -> subject.initializePlugins());
    assertThat(ex.getMessage(), Matchers.containsString("Application did not start properly"));
  }

  @Test
  public void pluginPrecedenceIsRespected() throws Exception {
    Path userHome = Files.createTempDirectory("rs-home");
    userHome.resolve("plugins").toFile().mkdirs();
    appHome = Files.createTempDirectory("app-home");
    appHome.resolve("plugins").toFile().mkdirs();

    Path myPlugin = Files.createTempFile("my-plugin", ".jar");

    InOrder inOrder = inOrder(pluginRepository);

    CliRoot options = new CliRoot();
    options.setHomeDir(userHome.toString());
    options.setPluginPaths(Arrays.asList(myPlugin));
    commonInitialization(options);

    inOrder.verify(pluginRepository).addPath(eq(myPlugin.toFile()));
    // $USER_HOME/plugins is not scanned any more
    inOrder.verify(pluginRepository, never()).scanDirectory(userHome.resolve("plugins"));
    inOrder.verify(pluginRepository).scanDirectory(appHome.resolve("plugins"));
  }

  @Test
  public void pluginsFromSettingsIniAreLoaded() throws Exception {
    Path userHome = Files.createTempDirectory("rs-home");
    userHome.resolve("plugins").toFile().mkdirs();
    appHome = Files.createTempDirectory("app-home");
    appHome.resolve("plugins").toFile().mkdirs();

    Path myPlugin = Files.createFile(userHome.resolve("my-plugin.jar"));

    Files.write(userHome.resolve("settings.ini"),
        String.join(OsUtils.LINE_SEPARATOR,
            "[global]",
            "load-plugin = my-plugin.jar",
            "no-core-plugins = 0"
        ).getBytes()
    );


    InOrder inOrder = inOrder(pluginRepository);

    CliRoot options = new CliRoot();
    options.setHomeDir(userHome.toString());
    commonInitialization(options, NO_NUKE_SETTINGS);

    inOrder.verify(pluginRepository).addPath(eq(myPlugin.toFile()));
    inOrder.verify(pluginRepository).scanDirectory(appHome.resolve("plugins"));
  }

  @Test
  public void pluginsFromSettingsIniAreLoadedByName() throws Exception {
    Path userHome = Files.createTempDirectory("rs-home");
    userHome.resolve("plugins").toFile().mkdirs();
    appHome = Files.createTempDirectory("app-home");
    Path appPlugins = appHome.resolve("plugins");
    Path appPluginsOptional = appHome.resolve("plugins-optional");
    Files.createDirectory(appPlugins);
    Files.createDirectory(appPluginsOptional);

    // make a foo plugin in APP_HOME/plugins
    Path fooPlugin = Files.createDirectory(appPlugins.resolve("foo"));
    // make a bar plugin in APP_HOME/plugins-optional
    Path barPlugin = Files.createDirectory(appPluginsOptional.resolve("bar"));
    // make a baz plugin in USER_HOME/
    Path bazPlugin = Files.createDirectory(userHome.resolve("baz"));

    Files.write(userHome.resolve("settings.ini"),
        String.join(OsUtils.LINE_SEPARATOR,
            "[global]",
            "load-plugin = foo",
            "load-plugin = bar",
            "load-plugin = baz",
            "no-core-plugins = true"
        ).getBytes()
    );


    InOrder inOrder = inOrder(pluginRepository);

    CliRoot options = new CliRoot();
    options.setHomeDir(userHome.toString());
    commonInitialization(options, NO_NUKE_SETTINGS);

    inOrder.verify(pluginRepository).addPath(eq(fooPlugin.toFile()));
    inOrder.verify(pluginRepository).addPath(eq(barPlugin.toFile()));
    inOrder.verify(pluginRepository).addPath(eq(bazPlugin.toFile()));
    inOrder.verify(pluginRepository, never()).scanDirectory(appHome.resolve("plugins"));
  }

  @Test
  public void pluginsFromSettingsIniAreLoadedAndCanSkipCore() throws Exception {
    Path userHome = Files.createTempDirectory("rs-home");
    userHome.resolve("plugins").toFile().mkdirs();
    appHome = Files.createTempDirectory("app-home");
    appHome.resolve("plugins").toFile().mkdirs();

    Path myPlugin = Files.createFile(userHome.resolve("my-plugin.jar"));

    Files.write(userHome.resolve("settings.ini"),
        String.join(OsUtils.LINE_SEPARATOR,
            "[global]",
            "load-plugin = my-plugin.jar",
            "no-core-plugins = true"
        ).getBytes()
    );


    InOrder inOrder = inOrder(pluginRepository);

    CliRoot options = new CliRoot();
    options.setHomeDir(userHome.toString());
    commonInitialization(options, NO_NUKE_SETTINGS);

    inOrder.verify(pluginRepository).addPath(eq(myPlugin.toFile()));
    // we shouldn't scan core plugin directory
    inOrder.verify(pluginRepository, never()).scanDirectory(appHome.resolve("plugins"));
  }

  @Test
  public void settingRandomSeedUpdatesRandomSingleton() throws Exception {
    CliRoot options = new CliRoot();
    options.setRandomSeed(55L);
    commonInitialization(options);
    assertEquals(55L, RandomSingleton.getSeed().longValue());
  }

  @Test
  public void failedPluginStartupCausesExitException() throws Exception {
    Plugin mockPlugin = mock(Plugin.class);
    when(mockPlugin.getId()).thenReturn("foo");
    URL url = Paths.get(".").toUri().toURL();
    Manifest manifest = new Manifest();

    manifest.getMainAttributes().putValue("Riskscape-Plugin", "foo");
    PluginClassLoader classLoader = PluginClassLoader.newDummy();
    PluginDescriptor pluginDescriptor = new DefaultPluginDescriptor(url, classLoader, manifest);
    when(mockPlugin.getDescriptor()).thenReturn(pluginDescriptor);

    doThrow(new PluginRuntimeException(pluginDescriptor, null, "went badly")).when(mockPlugin).startUp(any(), any());

    when(pluginRepository.activateAll(any())).thenAnswer(args -> {
      Consumer<Plugin> p = args.getArgument(0);
      p.accept(mockPlugin);
      return null;
    });
    CliBootstrap bs = new CliBootstrap();
    bs.setPluginRepository(pluginRepository);
    Assert.assertThrows(ExitException.class, () -> bs.startPlugins());
  }

  @Test
  public void aBrokenProjectThrowsAnExitExceptionThatWrapsTheProblemsFromTheBuilder() {
    CliRoot options = defaultCliRoot();
    options.setProjectIni("/tmp/does-not-exist.txt");
    commonInitialization(options);

    Engine engine = subject.buildEngine();
    assertEquals("test-build", engine.getBuildInfo().getVersion()); //Sets the version from build.properties
    assertNotNull(engine);

    ExitException ex = assertThrows(ExitException.class, () -> subject.buildProject());
    // here we check these two problems are contained by the exit exception
    assertThat(
      ex.getProblem(),
      equalTo(Problems.foundWith("--project",
        ResourceProblems.get().notFound(URI.create("file:///tmp/does-not-exist.txt"))
      ))
    );
  }

  @Test
  public void userGetsWarningWhenNoProjectIniOrHomeDirSpecified() {
    CliRoot noHomeOrProjectIni = new CliRoot();
    commonInitialization(noHomeOrProjectIni);
    when(pluginRepository.getActivated()).thenReturn(Collections.emptyList());

    subject.buildEngine();
    Project builtProject = subject.buildProject();

    assertThat(terminal.getCollectedProblems(), contains(CliBootstrap.PROBLEMS.noProjectFile()));
    // with no project.ini or home directory the built project should be relative to the current working
    // directory
    assertThat(builtProject.getRelativeTo(), is(Paths.get("").toUri()));
  }

  @Test
  public void userGetsWarningWhenNoProjectIniAndHomeDirSpecified() {
    CliRoot noProjectIni = defaultCliRoot();
    commonInitialization(noProjectIni);
    when(pluginRepository.getActivated()).thenReturn(Collections.emptyList());

    subject.buildEngine();
    subject.buildProject();
    assertThat(terminal.getCollectedProblems(), contains(CliBootstrap.PROBLEMS.noProjectFile()));
  }

  @Test
  public void userGetsNoWarningWhenProjectIniButNoHomeDirSpecified() throws Exception {
    appHome = Files.createTempDirectory("app-home");
    Path myProjectIni = Files.createTempFile("empty", ".ini");

    CliRoot noHome = new CliRoot();
    noHome.setProjectIni(myProjectIni.toString());
    commonInitialization(noHome);
    when(pluginRepository.getActivated()).thenReturn(Collections.emptyList());
    subject.buildEngine();
    subject.buildProject();
    assertThat(terminal.getCollectedProblems(), not(contains(CliBootstrap.PROBLEMS.noProjectFile())));
    remove(appHome);
  }

  @Test
  public void canSetLogLevelsFromCli() throws Exception {
    subject.setApplicationHome(appHome);
    subject.setTerminal(terminal);

    // noop cases
    assertTrue(CliBootstrap.parseLogLevelFromArgs(new String[] {}, subject.getTerminal()).isEmpty());
    assertTrue(CliBootstrap.parseLogLevelFromArgs(new String[] {"foo", "bar", "baz"}, subject.getTerminal()).isEmpty());
    assertTrue(CliBootstrap.parseLogLevelFromArgs(new String[] {"--log", "level", "baz"}, subject.getTerminal()).
        isEmpty());
    assertTrue(CliBootstrap.parseLogLevelFromArgs(new String[] {"--log-level"}, subject.getTerminal()).
        isEmpty());

    assertThat(
        CliBootstrap.parseLogLevelFromArgs(new String[] {"--log-level=warn"}, subject.getTerminal()),
        contains(
            loglevel("ROOT", Level.WARN),
            loglevel("nz.org.riskscape", Level.WARN)
        )
    );

    // can be space separated
    assertThat(
        CliBootstrap.parseLogLevelFromArgs(new String[] {"--log-level", "warn"}, subject.getTerminal()),
        contains(
            loglevel("ROOT", Level.WARN),
            loglevel("nz.org.riskscape", Level.WARN)
        )
    );

    // named logger
    assertThat(
        CliBootstrap.parseLogLevelFromArgs(new String[] {"--log-level=foo=INFO"}, subject.getTerminal()),
        contains(
            loglevel("foo", Level.INFO)
        )
    );

    // named logger can be space separated
    assertThat(
        CliBootstrap.parseLogLevelFromArgs(new String[] {"--log-level", "foo=INFO"}, subject.getTerminal()),
        contains(
            loglevel("foo", Level.INFO)
        )
    );

    // subsequent args are left 'unmolested'
    assertThat(
        CliBootstrap.parseLogLevelFromArgs(new String[] {"--log-level", "bar=ERROR", "baz=DEBUG"},
            subject.getTerminal()),
        contains(
            loglevel("bar", Level.ERROR)
        )
    );

    // multiple can be set by comma separating
    assertThat(
        CliBootstrap.parseLogLevelFromArgs(new String[] {"--log-level=foo=INFO,baz=ERROR"}, subject.getTerminal()),
        contains(
            loglevel("foo", Level.INFO),
            loglevel("baz", Level.ERROR)
        )
    );

    // magic dot syntax
    assertThat(
        CliBootstrap.parseLogLevelFromArgs(new String[] {"--log-level=.foo=INFO"}, subject.getTerminal()),
        contains(
            loglevel("nz.org.riskscape.foo", Level.INFO)
        )
    );

    //order is preserved
    assertThat(
        CliBootstrap.parseLogLevelFromArgs(new String[] {"--log-level=foo=INFO,WARN,baz=ERROR"}, subject.getTerminal()),
        contains(
            loglevel("foo", Level.INFO),
            loglevel("ROOT", Level.WARN),
            loglevel("nz.org.riskscape", Level.WARN),
            loglevel("baz", Level.ERROR)
        )
    );
  }

  public Matcher<Pair<Logger, Level>> loglevel(String name, Level level) {
     return new DiagnosingMatcher<Pair<Logger,Level>>() {

      @Override
      public void describeTo(Description description) {
        description.appendText("logger ").appendValue(name).appendText(" at level ").appendValue(level);
      }

      @Override
      protected boolean matches(Object item, Description mismatchDescription) {
        try {
          Pair<?, ?> pair = (Pair<?, ?>) item;

          Logger matchLogger = (Logger) pair.getLeft();
          Level matchLevel = (Level) pair.getRight();

          mismatchDescription.appendText("logger ").appendValue(matchLogger.getName())
            .appendText(" at level ").appendValue(matchLevel);

          return matchLogger.getName().equals(name) && level.equals(matchLevel);
        } catch (ClassCastException e) {
          return false;
        }
      }
    };
  }

}
