/*
 * 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 org.junit.Assert.*;

import java.io.BufferedReader;
import java.io.File;
import java.io.FileReader;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.io.Reader;
import java.io.StringReader;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.AbstractMap;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.EnumSet;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.function.Consumer;
import java.util.regex.Pattern;
import java.util.stream.Collectors;

import org.json.JSONObject;
import org.junit.After;
import org.junit.Before;
import org.junit.experimental.categories.Category;

import com.csvreader.CsvReader;
import com.google.common.collect.ImmutableMap;
import com.google.common.collect.Lists;
import com.google.common.collect.Streams;

import lombok.Getter;
import nz.org.riskscape.engine.OsUtils;
import nz.org.riskscape.engine.TemporaryDirectoryTestHelper;
import nz.org.riskscape.engine.Tuple;
import nz.org.riskscape.engine.Tuple.CoerceOptions;
import nz.org.riskscape.engine.types.Struct;

@Category(CliIntegrationTest.class)
public class BaseCliIntegrationTest implements TemporaryDirectoryTestHelper {

  private static final String HOME_RISKSCAPE=("/home\\riskscape");
  private static final String OPT_RISKSCAPE_CLI=("/opt\\riskscape-cli");

  protected String riskscapeDir = Paths.get("..", "..", "home").toAbsolutePath().toString();
  // set this field before execute to include extra/different plugins
  protected List<String> plugins = Arrays.asList();

  protected RunParameters params = new RunParameters(this);

  //Directory that files can be created in.
  protected Path outputDir;

  // if referencing files during test, they should be relative to this path, as this directory can be different from
  // outputDir, .e.g for specifiying the output path
  // TODO add this as a convenience to RunParameters
  protected Path mappedOutputDir;

  //Set to docker image to use for test.
  protected String dockerImageName = System.getenv().getOrDefault("RISKSCAPE_CLI_IMAGE", "riskscape-cli");
  //Set to docker network to use for test
  protected String dockerNetworkName = System.getenv().getOrDefault("RISKSCAPE_DOCKER_NETWORK", "riskscape-test");

  // JAVA_HOME to use when running in docker.
  private String dockerJavaHome = System.getenv().get("RISKSCAPE_DOCKER_JAVA_HOME");

  /**
   * If set the RISKSCAPE_HOME environment variables will be set to the riskscape home directory.
   *
   * This is required on older tests that use home directories, but new tests should turn this off and
   * use a project.ini instead.
   */
  protected boolean populateRiskscapeHomeEnv = true;

  public static class ExecResult implements TemporaryDirectoryTestHelper {
    public Process process;
    public Integer retCode;
    public List<String> allOutput = Collections.synchronizedList(Lists.newArrayList());

    @Getter
    public List<String> stdout = new ArrayList<>();
    @Getter
    public List<String> stderr = new ArrayList<>();

    public void addOutput(String streamName, String line) {
      if (streamName.equals("stdout")) {
        stdout.add(line);
      }
      if (streamName.equals("stderr")) {
        stderr.add(line);
      }
      allOutput.add(line);

    }
  }

  protected Path getInstallDir() {
    if (OsUtils.isLinux()) {
      return Paths.get("/opt/riskscape-cli").toAbsolutePath();
    } else {
      throw new RuntimeException("Don't know install dir...");
    }
  }

  /**
   * Creates the output directory relative to riskscapeDir.
   *
   * This really needs to be called after the riskscapeDir has been setup.  Can do this by overridding setup to set
   * riskscapeDir then call super.setup().
   *
   */
  @Before
  public void setup() throws Exception {
    sniffRiskscapeDir();
    setupOutputDir();
  }

  protected void setupOutputDir() throws Exception {
    outputDir = new File(riskscapeDir).toPath().resolve("output");
    File outDir = outputDir.toFile();
    if (! outDir.exists()) {
      Files.createDirectory(outputDir);
    }
    if (! outDir.isDirectory()) {
      throw new RuntimeException("Out directory is not a directory");
    }

    if (OsUtils.isLinux()) {
      // docker-mapped path
      mappedOutputDir = Paths.get("/home", "riskscape", "output");
    } else {
      mappedOutputDir = outputDir;
    }
  }

  protected void sniffRiskscapeDir() {
    Path conventionalPath = Paths.get("src", "test", "homes", getClass().getSimpleName());

    if (conventionalPath.toFile().exists()) {
      riskscapeDir = conventionalPath.toAbsolutePath().toString();
    }
  }

  @After
  public void cleanup() throws Exception {
    if (outputDir != null) {
      remove(outputDir);
    }
  }

  protected String expand(String string) {
    return new File(string).getAbsolutePath();
  }

  protected Path testdir(String segment) {
    return Paths.get("src", "test", "homes", segment).toAbsolutePath();
  }

  protected ExecResult execute(RunParameters extra) {
    return doExecute(Optional.empty(), extra);
  }

  protected ExecResult execute(Optional<Inputter> inputter, RunParameters extra) {
    return doExecute(inputter, extra);
  }

  private ExecResult doExecute(Optional<Inputter> inputter, RunParameters extra) {


    if (! new File(riskscapeDir).exists()) {
      //We don't want to run docker if the the riskscape dir does not exist because docker will create
      //the directory(on the host) but owned by root.
      fail("riskscapeDir: " + riskscapeDir + " does not exist");
    }
    final RunParameters prefix = new RunParameters(this);

    if(OsUtils.isLinux()) {

      String uid = String.format("%s:%s", fetchUid(), fetchGid());

      prefix.add("docker")
          .add("run", "--rm")
          .add("--user", uid)
          .addPath("-v", riskscapeDir,":","/home","riskscape")
          .addPath("-w", "/home", "riskscape")
          .add("--entrypoint", "/opt/riskscape-cli/bin/riskscape")
          .add("--network", dockerNetworkName)
          //Set user.home with JVM_OPTS because we don't expect the docker image to recognise our UID
          .add("--env", "JVM_OPTS=-Duser.home=/home/riskscape");

      if (dockerJavaHome != null) {
        prefix.add("--env", "JAVA_HOME=" + dockerJavaHome);
      }

      if (populateRiskscapeHomeEnv) {
        prefix.add("--env", "RISKSCAPE_HOME=/home/riskscape");
      }

      inputter.ifPresent(i -> prefix.add("--interactive"));

      prefix.add(dockerImageName)
          .add("--show-stacktrace")
          .add("--pipeline-threads=1");
    } else {
      Path rsd = Paths.get(riskscapeDir);
      String rsds = rsd.toAbsolutePath().toString();

      String dotbat = Paths.get("..", "cli", "build", "install", "riskscape", "bin", "riskscape.bat")
          .toAbsolutePath()
          .toString();

      prefix.add(dotbat)
          .add("-b="+rsds)
          .add("--show-stacktrace")
          .add("--pipeline-threads=1");
    }

    for (String pluginJar : plugins) {
      if(OsUtils.isLinux()) {

        // separate adds since there's no = between the -l and its param.
        prefix.add("-l")
          .add(Paths.get("/opt","riskscape-cli","plugins",pluginJar).toAbsolutePath().toString());
      } else {
        // addPath has special knowledge of /opt/riskscape-cli
        prefix.addPath("-l", "/opt","riskcape-cli","plugins",pluginJar);
      }
    }
    List<String> commandList = new ArrayList<String>();
    commandList.addAll(prefix.toList());
    commandList.addAll(extra.toList());

    return exec(Paths.get(".."), commandList, inputter);
  }

  public static ExecResult exec(Path workDir,  List<String> commandList, Optional<Inputter> inputter) {
    ProcessBuilder pbuilder = new ProcessBuilder();

    pbuilder.directory(workDir.toFile());

    ExecResult result = new ExecResult();

    System.out.println(commandList.stream().map(Object::toString).collect(Collectors.joining(" ")));

    pbuilder.command(commandList);
    try {
      Process process = pbuilder.start();

      inputter.ifPresent(i -> {
        i.start(process.getOutputStream());
      });

      List<Thread> threads = Arrays.asList(
        capture(result, "stderr", process.getErrorStream()),
        capture(result, "stdout", process.getInputStream()),
        inputter.orElse(null)
      );

      int retCode = process.waitFor();
      result.retCode = retCode;

      threads.forEach(t -> {
        try {
          if (t != null) {
            t.join();
          }
        } catch (InterruptedException e) {
          e.printStackTrace();
        }
      });
      return result;
    } catch (IOException e) {
      throw new RuntimeException(e);
    } catch (InterruptedException e) {
      e.printStackTrace();
      throw new RuntimeException(e);
    }

  }

  private String fetchUid() {
    ExecResult result = exec(Paths.get("."), Arrays.asList("id", "-u"), Optional.empty());
    return result.stdout.get(0).trim();
  }

  private String fetchGid() {
    ExecResult result = exec(Paths.get("."), Arrays.asList("id", "-g"), Optional.empty());
    return result.stdout.get(0).trim();
  }

  public static Thread capture(ExecResult result, String streamName, InputStream stream) {
    BufferedReader reader = new BufferedReader(new InputStreamReader(stream));
    Thread thread = new Thread(() ->  {
      try {
        String line = null;
        while ((line = reader.readLine()) != null) {
          System.err.println(String.format("[%s] %s", streamName, line));
          result.addOutput(streamName, line);
        }
      } catch (IOException e) {
        e.printStackTrace();
      }

    });

    thread.start();

    return thread;

  }

  protected List<Tuple> parseOutput(ExecResult result, Struct type) {
    EnumSet<CoerceOptions> options = EnumSet.of(CoerceOptions.SURPLUS_IGNORED, CoerceOptions.MISSING_IGNORED);
    return result.stdout
        .stream()
        .map(str -> Tuple.coerce(type, new JSONObject(str).toMap(), options))
        .collect(Collectors.toList());
  }

  protected List<Map<String, Object>> parseJsonFromOutput(String... pathElements) throws IOException {
    List<String> lines = Files.readAllLines(Paths.get(outputDir.toString(), pathElements));

    return lines.stream()
        .map(line -> new JSONObject(line).toMap())
        .collect(Collectors.toList());
  }

  protected List<Map<String, String>> parseCsv(ExecResult result) throws IOException {
    CsvReader reader = new CsvReader(new StringReader(result.stdout.stream().collect(Collectors.joining("\n"))));
    reader.readHeaders();
    List<Map<String, String>> rows = Lists.newArrayList();
    List<String> header = Arrays.asList(reader.getHeaders());
    while (reader.readRecord()) {
      List<String> values = Arrays.asList(reader.getValues());

      rows.add(ImmutableMap.copyOf(Streams.zip(header.stream(), values.stream(), (a, b) -> {
        return new AbstractMap.SimpleImmutableEntry<>(a, b);
      }).collect(Collectors.toList())));

    }

    return rows;
  }

  public List<List<String>> openCsv(String fname, String... expectedHeader) throws IOException {
    Path outputPath = outputDir.resolve(fname);

    if (!outputPath.toFile().isFile()) {
      String contents = Arrays.asList(outputDir.toFile().list()).stream().collect(Collectors.joining("\n"));
      assertTrue(outputPath + " not a file\n" + contents, outputPath.toFile().isFile());
    }
    CsvReader reader = new CsvReader(new FileReader(outputPath.toFile()));
    reader.readHeaders();
    String[] actualHeader = reader.getHeaders();

    assertEquals(
      Arrays.asList(expectedHeader),
      Arrays.asList(actualHeader)
    );

    List<List<String>> rows = new ArrayList<>();
    while (reader.readRecord()) {
      rows.add(Arrays.asList(reader.getValues()));
    }
    return rows;
  }

  protected void assertOutputExists(String... pathElements) {
    Path path = Paths.get(outputDir.toString(), pathElements);
    assertTrue("Expected output directory to contain " + path.toString(), path.toFile().exists());

  }

  /**
   * Assert that the given file contains at least the given list of rows.
   */
  protected void assertFileHasLines(List<String> subsetOfExpectedRows, Path outfile) throws IOException {
    List<String> working = new ArrayList<>(subsetOfExpectedRows);

    forEachLineInFile(outfile, (line) -> {
      working.remove(line);
    });

    if (!working.isEmpty()) {
      fail(String.format("Expected %s to contain these rows: %s", outfile, working));
    }
  }

  /**
   * Iterate over the given file, line by line
   */
  protected void forEachLineInFile(Path outfile, Consumer<String> consumer) throws IOException {
    try (BufferedReader reader = createBufferedReader(outfile)) {
      String line;
      while ((line = reader.readLine()) != null) {
        consumer.accept(line);
      }
    }
  }

  /**
   * Creates a BufferedReader from a path to allow line-by-line reading of the file
   */
  public static BufferedReader createBufferedReader(Path outfile) throws IOException {
    return new BufferedReader(new FileReader(outfile.toFile()));
  }

  /**
   * Assert that the given file contains the given number of rows
   */
  protected void assertHasLineCount(int i, Path outfile) throws IOException {
    int[] counter = new int[] {0};
    forEachLineInFile(outfile, (l) -> counter[0]++);

  }

  /**
   * Check that the given csv content contains at least the given expected records.
   * @param expected map of records, identified by key column
   * @param keyColumn the name of the column in the csv that identifies rows
   * @param valueKeys the names of the columns that should be checked against the matching row.  Corresponds to the
   * values of the expected map.
   */
  public static void assertHasRecords(Map<String, List<String>> expected, Path csv, String keyColumn,
      String... valueKeys) throws IOException {

    assertHasRecords(expected, createBufferedReader(csv), keyColumn, valueKeys);
  }

  /**
   * Check that the given csv content contains at least the given expected records.
   * @param expected map of records, identified by key column
   * @param keyColumn the name of the column in the csv that identifies rows
   * @param valueKeys the names of the columns that should be checked against the matching row.  Corresponds to the
   * values of the expected map.
   */
  public static void assertHasRecords(Map<String, List<String>> expected, Reader csv, String keyColumn,
      String... valueKeys) throws IOException {

    CsvReader reader = new CsvReader(csv);
    reader.setSafetySwitch(false);
    reader.readHeaders();

    while (reader.readRecord()) {
      String key = reader.get(keyColumn);
      if (expected.containsKey(key)) {
        List<String> expectedValues = expected.get(key);

        List<String> actualValues = Arrays.asList(valueKeys).stream().map(k -> {
          try {
            return reader.get(k);
          } catch (IOException e) {
            throw new RuntimeException(e);
          }
        }).collect(Collectors.toList());

        assertEquals(expectedValues, actualValues);
      }
    }
  }

  /**
   * Reads the desired columns from a csv file.
   * @param csv     path to csv file to read
   * @param column  the columns to read from csv file
   */
  public static List<List<String>> readColumnsFromCsv(Path csv, String... column) {
    try {
      CsvReader reader = new CsvReader(createBufferedReader(csv));
      reader.setSafetySwitch(false);
      reader.readHeaders();

      List<List<String>> results = new ArrayList<>();
      while (reader.readRecord()) {
        List<String> line = new ArrayList<>();
        for (String c : column) {
          String cell = reader.get(c);
          if (cell.isEmpty()) { // Might have been messed up by a BOM
            cell = reader.get("\uFEFF" + c);
          }
          line.add(cell);
        }
        results.add(line);
      }

      return results;
    } catch (IOException e) {
      throw new RuntimeException(e);
    }
  }

  /**
   * convert docker based paths to non-docker based riskscapeDir based
   * paths (if windows).
   *
   * @param dockerPath
   * @return non-docker based riskscapeDir based paths.
   */
  protected String toRSDPath(String dockerPath) {
    String ret = dockerPath;

    if (OsUtils.isWindows()) {

      // not anchored on "^" anymore.  For more general use.
      if (dockerPath.matches(Pattern.quote(HOME_RISKSCAPE) + ".*")) {
        ret = dockerPath.replace(HOME_RISKSCAPE, riskscapeDir);
      } else if (dockerPath.matches(Pattern.quote(OPT_RISKSCAPE_CLI) + ".*")) {
        ret = dockerPath.replace(OPT_RISKSCAPE_CLI, "..\\cli\\build\\install\\riskscape");
        ret = new File(ret).getAbsolutePath();
      } else {
        // if windows and not either of those absolute paths above, then
        // just anchor on riskscapeDir.
        ret = riskscapeDir + File.separator + dockerPath;
      }
    }

    return ret;
  }

  protected Tuple wrapAsLoss(Tuple toWrap) {
    return Tuple.of(Struct.of("loss", toWrap.getStruct()), "loss", toWrap);
  }

}
