/*
 * 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 static org.hamcrest.Matchers.*;
import static org.junit.Assert.*;

import java.io.ByteArrayOutputStream;
import java.io.FileReader;
import java.io.IOException;
import java.io.PrintStream;
import java.net.URI;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.LinkedList;
import java.util.List;
import java.util.Set;
import java.util.stream.Collectors;

import org.hamcrest.Description;
import org.hamcrest.Matcher;
import org.hamcrest.TypeSafeDiagnosingMatcher;
import org.junit.After;
import org.junit.Before;
import org.junit.experimental.categories.Category;
import org.junit.runner.RunWith;
import org.slf4j.LoggerFactory;

import com.opencsv.CSVReader;
import com.opencsv.exceptions.CsvValidationException;

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.config.Config;
import nz.org.riskscape.engine.DefaultEngine;
import nz.org.riskscape.engine.DefaultProject;
import nz.org.riskscape.engine.FileSystemMatchers;
import nz.org.riskscape.engine.Project;
import nz.org.riskscape.engine.TemporaryDirectoryTestHelper;
import nz.org.riskscape.engine.cli.ApplicationCommand;
import nz.org.riskscape.engine.cli.CliIntegrationTest;
import nz.org.riskscape.engine.cli.CliRoot;
import nz.org.riskscape.engine.cli.EngineOnlyCommand;
import nz.org.riskscape.engine.cli.QuickCliIntegrationTest;
import nz.org.riskscape.engine.cli.SwitchableByteArrayInputStream;
import nz.org.riskscape.engine.cli.TestTerminal;
import nz.org.riskscape.engine.cli.model.RunCommand;
import nz.org.riskscape.engine.i18n.Messages;
import nz.org.riskscape.engine.pipeline.ExecutionContext;
import nz.org.riskscape.engine.plugin.Plugin;
import nz.org.riskscape.problem.Problem;
import nz.org.riskscape.problem.ResultOrProblems;

/**
 * Base test class for executing an engine or project cli command against a realistically populated engine or project
 *
 * TODO needs some work to get this suitable for use with external plugin projects:
 *   - how to load plugins when not in dev env?
 *   - riskscape home isn't found
 */
// these will run at the same time as the other, heavier, integration tests - but we could create a new category and
// run these in parallel with the other integration tests
@Category({CliIntegrationTest.class, QuickCliIntegrationTest.class})
@RunWith(EngineTestRunner.class)
@Slf4j
public abstract class EngineCommandIntegrationTest implements EngineTest, TemporaryDirectoryTestHelper {

  public static final char BOM = '\uFEFF';

  public static final Path EMPTY_PROJECT =
      Paths.get("src", "test", "homes", "RM419_Project_File", "empty-project.ini");

  // This used to be riskscape.org.nz in all the tests, but now we've switched docs to a microsite
  // we don't proxy these anymore
  public static final String HTTPS_TEST_URI =
      "object-storage.nz-hlz-1.catalystcloud.io/v1/AUTH_aa20cc6a73bd4a27a1b0348a5704ae7f/rel";

  @Setter @Getter
  protected DefaultEngine engine;

  @Setter @Getter
  protected List<Plugin> plugins;

  @Setter @Getter
  protected Messages messages;

  public ByteArrayOutputStream errBytes;
  public ByteArrayOutputStream outBytes;
  public TestTerminal terminal;

  public DefaultProject project;
  public CliRoot cliRoot;

  public String inputString = "";

  public long testStarted;

  public void setupProject(Config config) {
    project = new DefaultProject(engine, config);
  }

  @Override
  public void setProblemSinkProxy(ProblemSinkProxy problemSinkProxy) {
    problemSinkProxy.addProblemSink(p -> collectedSinkProblems.add(p));
  }

  @Before
  public void setupLogLevel() {
    // note we don't turn off ALL logging here, as some info messages are still
    // useful, e.g. when a struct isn't normalized
    String[] noisyLogSources = new String[] {
        "nz.org.riskscape.engine.data.relation.LockDefeater",
        "nz.org.riskscape.engine.sched.Scheduler"
    };
    for (String source : noisyLogSources) {
      final Logger logger = (Logger) LoggerFactory.getLogger(source);
      logger.setLevel(Level.ERROR);
    }
  }

  @Before
  public void setStartTime() {
    testStarted = System.currentTimeMillis();
  }

  @After
  public void logTestTime() {
    // log so we identify slow tests and just see our general progress through the suite
    long nowMs = System.currentTimeMillis();
    log.info("Test took {} ms", nowMs - testStarted);
  }

  private Path tempDirectory;

  // this collects warnings/info messages emitted to the engine's problemSink
  protected List<Problem> collectedSinkProblems = new ArrayList<>();

  private SwitchableByteArrayInputStream commandInputStream = new SwitchableByteArrayInputStream();

  public Path getTempDirectory() {
    if (tempDirectory == null) {
      try {
        tempDirectory = Files.createTempDirectory(getClass().getSimpleName());
      } catch (IOException e) {
        throw new RuntimeException(e);
      }
    }
    return tempDirectory;
  }

  @Before
  public void setupTerminal() {
    this.errBytes = new ByteArrayOutputStream();
    this.outBytes = new ByteArrayOutputStream();

    this.terminal = new TestTerminal(new PrintStream(errBytes), new PrintStream(outBytes),
        commandInputStream, messages);
  }

  @Before
  public void setupCliRoot() {
    this.cliRoot = new CliRoot();
  }

  /**
   * Reset the the test environment so it is good for the next call of {@link RunCommand#run() }.
   *
   * This is done automatically between tests but will need to be called manually
   * by tests that call {@link RunCommand#run() } more than once.
   */
  @Before
  public void reset() {
    // XXX we have a nasty bug (https://gitlab.catalyst.net.nz/riskscape/riskscape/-/issues/389) where some
    // of the cached srid set stuff is maybe not using the right cache
    // keys, so we're getting some random test results at times.  This short-term work around is to clear
    // the cache between test cases just so that CI shuts up, but note that this is still a real bug out
    // in the wild
    // Note that this fix was originally applied in EngineTestRunner when the SRIDSet was part of the engine.
    if (project != null) {
      project.getSridSet().clear();
    }
    // otherwise we might need to assert things from a previous run
    collectedSinkProblems.clear();
  }

  /**
   * Remove any temporary file created following testing if
   * not a directory exist
   * @throws Exception
   */
  @After
  public void cleanup() throws Exception {
    if (tempDirectory != null) {
      remove(tempDirectory);
    }
  }


  /**
   * Supply a string to be read in as stdin (for commands that read from stdin) - defaults to empty string.
   */
  public void setCommandInput(String input) {
    this.commandInputStream.setBytes(input.getBytes());
  }

  /**
   * Setup standard dependencies on a command, so that it can be executed, e.g.
   * `Object result = setupCommand(new CommandType.SubCommand()).doCommand(project)`
   */
  protected <T extends ApplicationCommand> T setupCommand(T command) {
    command.setEngine(engine);
    command.setProject(project);
    command.setTerminal(terminal);
    command.setMessages(messages);

    return command;
  }

  protected <T extends EngineOnlyCommand> T setupCommand(T command) {
    command.setEngine(engine);
    command.setTerminal(terminal);
    command.setMessages(messages);

    return command;
  }

  /**
   * @return a path to the conventionally agreed location for a project's "home" directory.
   */
  public Path stdhome() {
    return homes().resolve(getClass().getSimpleName());
  }

  /**
   * @return a path to where all the test suite home directories are kept
   */
  public Path homes() {
    return Paths.get("src", "test", "homes");
  }

  /**
   * Run a project through the standard initialisation routine for a given project's ini file.
   * @param projectFile a path to a project file, or a directory containing a `project.ini` file, e.g. so you can
   * `populateProject(stdHome())`
   */
  public ResultOrProblems<Project> populateProject(Path projectFile) {
    return populateProject(projectFile.toUri());
  }

   public ResultOrProblems<Project> populateProject(URI projectFile) {
    List<Problem> collected = new LinkedList<>();
    ResultOrProblems<Project> builtOr = engine.buildProject(projectFile, p -> collected.add(p));

    if (builtOr.isPresent()) {
      this.project = (DefaultProject) builtOr.get();
    } else {
      this.project = new DefaultProject(this.engine, Config.EMPTY);
    }

    // mimic cli start up behaviour
    if (cliRoot.isShowProjectErrors()) {
      try (ExecutionContext ctx = project.newExecutionContext()) {
        project.validate(ctx, p -> collected.add(p));
      }
    }

    // this is a bit janky, as we might return errors in a successful result, but it makes it simpler for the test to
    // assert problems from this single place
    return builtOr.withMoreProblems(collected);
  }

  /**
   * Resolves a file from the per-test temp directory, printing out some debug if it's missing
   */
  public Path getOutputPath(String fname) throws IOException {
     Path outputPath = tempDirectory.resolve(fname);
     if (!outputPath.toFile().isFile()) {
       String contents = Arrays.asList(tempDirectory.toFile().list()).stream().collect(Collectors.joining("\n"));

       assertThat(tempDirectory, FileSystemMatchers.hasFile(FileSystemMatchers.fileWithName(fname)));
       assertTrue(outputPath + " not a file\n" + contents, outputPath.toFile().isFile());
     }

     return outputPath;
   }

  /**
   * Returns the contents of a file from the per-test temporary output directory.
   */
  public String openFile(String fname) throws IOException {
    return Files.readString(getOutputPath(fname));
  }

  /**
   * @return a {@link CSVReader} for a file in the per-test temporary output directory
   */
  public CSVReader openCsv(String fname) throws IOException {
    FileReader fileReader = new FileReader(getOutputPath(fname).toFile());
    // skip the first char if it's the BOM - if any tests want to check for the BOM they'll need to do it by opening
    // the file and checking for it there.
    int firstChar = fileReader.read();

    if (firstChar != BOM) {
      // wasn't the BOM - reopen the file and try again
      fileReader.close();
      fileReader = new FileReader(getOutputPath(fname).toFile());
    }

    return new CSVReader(fileReader);
  }

  /**
   * @param expectedHeader a list of the header values s to expect to find
   * @return a list of rows from a CSV file in the per-test temporary output directory
   */
  public List<List<String>> openCsv(String fname, String... expectedHeader) throws CsvValidationException, IOException {
    try (CSVReader reader = openCsv(fname)) {
      String[] actualHeader = reader.readNext();

      assertThat(
        Arrays.asList(actualHeader),
        equalTo(List.of(expectedHeader))
      );

      List<List<String>> rows = new ArrayList<>();
      reader.forEach(row -> rows.add(Arrays.asList(row)));

      return rows;
    }
  }

  /**
   * Reads only the specified `selectedColumns` (as opposed to all the columns,
   * like {@link #openCsv(String, String...)} does).
   */
  public List<List<String>> readCsvColumns(String fname, String... selectedColumns)
      throws CsvValidationException, IOException {
    try (CSVReader reader = openCsv(fname)) {
      List<String> actualHeader = Arrays.asList(reader.readNext());
      assertThat(actualHeader, hasItems(selectedColumns));

      List<List<String>> rows = new ArrayList<>();
      reader.forEach(row -> {
        List<String> values = Arrays.asList(row);
        List<String> selected = new ArrayList<>();
        for (String c : selectedColumns) {
          selected.add(values.get(actualHeader.indexOf(c)));
        }
        rows.add(selected);
      });

      return rows;
    }
  }

  public Set<List<String>> openCsvUniqueData(String fname, String... expectedHeader)
      throws CsvValidationException, IOException {
    List<List<String>> rows = openCsv(fname, expectedHeader);
    Set<List<String>> rowset = rows.stream().collect(Collectors.toSet());

    assertEquals("rowset contains non unique members", rowset.size(),  rows.size());
    return rowset;
  }

  public String render(Problem problem) {
    return messages.renderProblem(problem).toString();
  }

  public String render(List<Problem> problems) {
    return problems.stream().map(p -> messages.renderProblem(p).toString()).collect(Collectors.joining("\n"));
  }

  /**
   * @return a matcher that matches the rendered text for the immediate problem, but uses a second matcher for children
   */
  public Matcher<Problem> renderedAs(Matcher<String> message, Matcher<? super List<Problem>> childMatcher) {
    return new TypeSafeDiagnosingMatcher<Problem>() {

      @Override
      public void describeTo(Description desc) {
        desc.appendText("problem rendered ").appendDescriptionOf(message);

        if (childMatcher != null) {
          desc.appendText(" with children ").appendDescriptionOf(childMatcher);
        }
      }

      @Override
      protected boolean matchesSafely(Problem problem, Description desc) {
        String rendered = messages.renderProblem(problem).message;

        System.out.println(rendered);
        System.out.println(message);

        message.describeMismatch(rendered, desc);
        if (childMatcher != null) {
          childMatcher.describeMismatch(problem.getChildren(), desc);
        }

        return message.matches(rendered) && (childMatcher == null || childMatcher.matches(problem.getChildren()));
      }
    };
  }

  /**
   * Version of {@link #renderedAs(Matcher, Matcher)}, that doesn't match on children
   */
  public Matcher<Problem> renderedAs(Matcher<String> message) {
    return renderedAs(message, null);
  }
}
