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

import static nz.org.riskscape.engine.FileSystemMatchers.*;
import static org.hamcrest.Matchers.*;
import static org.junit.Assert.*;

import java.io.File;
import java.net.URI;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.LocalDateTime;
import java.util.Arrays;
import java.util.List;

import org.junit.After;
import org.junit.Before;
import org.junit.Test;

import com.codahale.metrics.Counter;
import com.codahale.metrics.Meter;
import com.google.common.collect.ImmutableMap;

import nz.org.riskscape.engine.DefaultEngine;
import nz.org.riskscape.engine.TemporaryDirectoryTestHelper;
import nz.org.riskscape.engine.Tuple;
import nz.org.riskscape.engine.pipeline.ExecutionContext;
import nz.org.riskscape.engine.resource.CreateHandle;
import nz.org.riskscape.engine.types.Nullable;
import nz.org.riskscape.engine.types.Struct;
import nz.org.riskscape.engine.types.Types;
import nz.org.riskscape.engine.util.StatsWriter;
import nz.org.riskscape.problem.Problem;
import nz.org.riskscape.problem.Problem.Severity;
import nz.org.riskscape.problem.Problems;
import nz.org.riskscape.problem.ResultOrProblems;

public class FileSystemPipelineOutputStoreTest extends BasePipelineOutputStoreTest
    implements TemporaryDirectoryTestHelper {

  // We pickle the time to make test assertions easier
  private static final LocalDateTime TEST_TIME = LocalDateTime.of(2022, 1, 1, 12, 0);

  Path outputBasePath;

  public FileSystemPipelineOutputStoreTest() {
    super(new FileSystemPipelineOutputStore(() -> TEST_TIME));
  }

  @Before
  public void setup() throws Exception{
    outputBasePath = createTempDirectory("FileSystemPipelineOutputStoreTest");
    project.setOutputBaseLocation(outputBasePath.toUri());
  }

  @After
  public void cleanup() throws Exception {
    remove(outputBasePath);
  }

  @Override
  protected void populateEngineWithDefaults(DefaultEngine newEngine) {
    super.populateEngineWithDefaults(newEngine);

    // we need some formats to do the actual writing
    newEngine.getFormats().add(new CsvFormat());
    newEngine.getFormats().add(new ShapefileFormat());
  }

  @Test
  public void isApplicableForFileURIs() {
    // The FileSystemPipelineOutputStore is the default catchall store. It should be applicable for
    // any URI value to ensure that there is a PipelineOutputStore that can be used.
    assertThat(subject.isApplicable(URI.create("foo")), is(PipelineOutputStore.PRIORITY_NA));
    assertThat(
      subject.isApplicable(URI.create("wfs://some-host/some-path")),
      is(PipelineOutputStore.PRIORITY_NA)
    );
    assertThat(subject.isApplicable(URI.create("file:foo")), is(PipelineOutputStore.PRIORITY_DEFAULT));
    assertThat(subject.isApplicable(URI.create("file:/foo/bar")), is(PipelineOutputStore.PRIORITY_DEFAULT));

  }

  @Test
  public void willUseTheGivenOuputDirectory() {
    try (PipelineOutputContainer container = getOutputContainer(outputBasePath.toUri()).get()) {
      writeSpatialData(container);
    }
    assertThat(outputBasePath, hasFile(fileWithName("spatial.shp")));
    assertThat(
      created.getPipelineOutputs(),
      equalTo(ImmutableMap.of("spatial", outputBasePath.resolve("spatial.shp").toUri()))
    );
  }

  @Test
  public void selectDefaultFormatBasedOnType() {
    try (PipelineOutputContainer container = getOutputContainer(outputBasePath.toUri()).get()) {
      writeSpatialData(container);
      writeNonSpatialData(container);
    }
    assertThat(outputBasePath, hasFile(fileWithName("spatial.shp")));
    assertThat(outputBasePath, hasFile(fileWithName("non-spatial.csv")));
    assertThat(
      created.getPipelineOutputs(),
      equalTo(ImmutableMap.of(
          "spatial", outputBasePath.resolve("spatial.shp").toUri(),
          "non-spatial", outputBasePath.resolve("non-spatial.csv").toUri()
      ))
    );
  }

  @Test
  public void multipleGeometryStoredToShapefile() {
    Struct type = Struct.of("foo", Types.GEOMETRY, "bar", Types.GEOMETRY);

    try (PipelineOutputContainer container = getOutputContainer(outputBasePath.toUri()).get()) {
      writeData(container, "multiple-geoms", null, Tuple.ofValues(type, point1, point1));
    }
    assertThat(outputBasePath, hasFile(fileWithName("multiple-geoms.shp")));
  }

  @Test
  public void nestedGeometryStoredToShapefile() {
    Struct type = Struct.of("foo", Types.TEXT, "bar", spatialType);

    try (PipelineOutputContainer container = getOutputContainer(outputBasePath.toUri()).get()) {
      writeData(container, "nested-geom", null, Tuple.ofValues(type, "bar", spatialTuple));
    }
    assertThat(outputBasePath, hasFile(fileWithName("nested-geom.shp")));
  }

  @Test
  public void nullGeometryStoredToShapefile() {
    Struct type = Struct.of("foo", Nullable.GEOMETRY, "bar", Types.TEXT);

    try (PipelineOutputContainer container = getOutputContainer(outputBasePath.toUri()).get()) {
      writeData(container, "nullable-geoms", null, Tuple.ofValues(type, point1, "baz"));
    }
    assertThat(outputBasePath, hasFile(fileWithName("nullable-geoms.shp")));
  }

  @Test
  public void incrementsFileOnDuplicateFiles() {
    // write the same output 3 times to new containers using the same uri
    List<String> expected = Arrays.asList("spatial.shp", "spatial-1.shp", "spatial-2.shp");
    for (String expectedShapefile : expected) {
      try (PipelineOutputContainer container = getOutputContainer(outputBasePath.toUri()).get()) {
        writeSpatialData(container);
      }
      assertThat(outputBasePath, hasFile(fileWithName(expectedShapefile)));

      assertThat(
        created.getPipelineOutputs(),
        equalTo(ImmutableMap.of(
          "spatial", outputBasePath.resolve(expectedShapefile).toUri()
        ))
      );
    }
  }

  @Test
  public void willReplaceFilesWithReplaceTrue() {
    PipelineOutputOptions options = defaultOptions();
    options.setReplace(true);
    long previousMtime = -1;
    for (int i = 0; i < 3; i++) {
      try (PipelineOutputContainer container = getOutputContainer(outputBasePath.toUri(), options).get()) {
        writeSpatialData(container);
      }
      assertThat(outputBasePath, hasFile(fileWithName("spatial.shp")));
      File file = outputBasePath.resolve("spatial.shp").toFile();
      if (file.lastModified() <= previousMtime) {
        fail("file was not modified!");
      }
      // and update the modification time
      previousMtime = file.lastModified();

      // make sure the modification time has a chance to increase
      try {
        Thread.sleep(10);
      } catch (InterruptedException e) {}

      // just in case, make sure nothing was rolled
      assertThat(outputBasePath, not(hasFile(fileWithName("spatial-1.shp"))));
    }
  }

  @Test
  public void willSaveInGlobalFormat() {
    PipelineOutputOptions options = defaultOptions();
    options.setFormat(new CsvFormat());
    try (PipelineOutputContainer container = getOutputContainer(outputBasePath.toUri(), options).get()) {
      writeSpatialData(container);
      writeNonSpatialData(container);
    }
    assertThat(outputBasePath, hasFile(fileWithName("spatial.csv")));
    assertThat(outputBasePath, hasFile(fileWithName("non-spatial.csv")));
  }

  @Test
  public void willSaveInSaveSpecifiedFormat() {
    try (PipelineOutputContainer container = getOutputContainer(outputBasePath.toUri()).get()) {
      writeSpatialData(container, "spatial", new CsvFormat());
      writeNonSpatialData(container, "non-spatial", new CsvFormat());
    }
    assertThat(outputBasePath, hasFile(fileWithName("spatial.csv")));
    assertThat(outputBasePath, hasFile(fileWithName("non-spatial.csv")));
  }

  @Test
  public void saveSpecifiedFormatTrumpsGlobalFormatSetting() {
    PipelineOutputOptions options = defaultOptions();
    options.setFormat(new CsvFormat());
    try (PipelineOutputContainer container = getOutputContainer(outputBasePath.toUri(), options).get()) {
      writeSpatialData(container, "spatial", new ShapefileFormat());
      writeNonSpatialData(container);
    }
    assertThat(outputBasePath, hasFile(fileWithName("spatial.shp")));
    assertThat(outputBasePath, hasFile(fileWithName("non-spatial.csv")));
  }

  @Test
  public void savesManifestAndPipelineDeclaration() throws Exception {
    try (PipelineOutputContainer container = getOutputContainer(outputBasePath.toUri()).get()) {
      writeSpatialData(container, "spatial", new CsvFormat());
    }
    assertThat(outputBasePath, hasFile(fileWithName("pipeline.txt")));
    List<String> pipelineContent = Files.readAllLines(outputBasePath.resolve("pipeline.txt"));
    assertThat(pipelineContent, contains(
        "input(value: 10)",
        "-> filter(value > 8)"
    ));

    assertThat(outputBasePath, hasFile(fileWithName("manifest.txt")));
    List<String> manifestContent = Files.readAllLines(outputBasePath.resolve("manifest.txt"));
    // the manifest lists the expected output file with no checksum (default setting)
    assertThat(manifestContent, hasItem(is("spatial.csv (size=30, checksum=null)")));
  }

  @Test
  public void willSaveManifestWithChecksums() throws Exception {
    PipelineOutputOptions options = defaultOptions();
    options.setChecksum(true);
    try (PipelineOutputContainer container = getOutputContainer(outputBasePath.toUri(), options).get()) {
      writeSpatialData(container, "spatial", new CsvFormat());
    }

    assertThat(outputBasePath, hasFile(fileWithName("manifest.txt")));
    List<String> manifestContent = Files.readAllLines(outputBasePath.resolve("manifest.txt"));
    // the manifest lists the expected output files along with checksums
    assertThat(manifestContent, allOf(
        hasItem(
            is("spatial.csv (size=30, checksum=3c91b128344d896345bff40f90b1d6470eb080cabeabd6f025979c014ba987c1)")
        ),
        hasItem(
            is("pipeline.txt (size=37, checksum=17c2b6a79b0192a8c95560f2d89f5e325e3afdd8d686b8adba487087eb445905)")
        )
    ));
  }

  @Test
  public void willSaveStats() throws Exception {
    // we need to populate some metrics
    Meter tuplesIn = pipeline.getContext().getMetricRegistry().meter("test.tuples-in");
    tuplesIn.mark();
    Counter runtime = pipeline.getContext().getMetricRegistry().counter("test.runtime");
    runtime.inc(2000);

    PipelineOutputOptions options = defaultOptions();
    options.setChecksum(true);
    try (PipelineOutputContainer container = getOutputContainer(outputBasePath.toUri(), options).get()) {
      writeSpatialData(container, "spatial", new CsvFormat());
    }

    assertThat(outputBasePath, hasFile(fileWithName(StatsWriter.FILENAME)));
    List<String> statsContent = Files.readAllLines(outputBasePath.resolve(StatsWriter.FILENAME));
    assertThat(statsContent, contains(
        is("﻿name,runtime-ms,runtime-average-ms,tuples-in,tuples-in-per-sec,tuples-out,tuples-out-per-sec,"
            + "context-switches"),
        containsString("test,2000,,1,,,,")
    ));
  }

  @Test
  public void arbitraryFilesCanBeTrackedAndRecordedAsOutputs() throws Exception {
    PipelineOutputContainer container = getOutputContainer(outputBasePath.toUri(), defaultOptions()).get();
    Path exampleOutput = outputBasePath.resolve("example.txt");
    Files.writeString(exampleOutput, "test this");
    container.registerLocalFile(exampleOutput);
    container.close();

    assertThat(container.getPipelineOutputs(), hasEntry("example.txt", exampleOutput.normalize().toUri()));
  }

  @Test
  public void arbitraryFilesOutsideOfStorageLocationGetMoved() throws Exception {
    PipelineOutputContainer container = getOutputContainer(outputBasePath.toUri(), defaultOptions()).get();
    Path externalDirectory = createTempDirectory("external");

    Path exampleOutput = externalDirectory.resolve("example.txt");
    Files.writeString(exampleOutput, "test this");
    container.registerLocalFile(exampleOutput);
    container.close();

    Path expectedLocationAfterClose = outputBasePath.resolve(exampleOutput.getFileName()).normalize();

    assertThat(
        container.getPipelineOutputs(),
        // path has changed
        hasEntry("example.txt", expectedLocationAfterClose.toUri())
    );

    assertFalse(Files.exists(exampleOutput));
    assertTrue(Files.exists(expectedLocationAfterClose));
  }

  static class CsvFormatWithWarning extends CsvFormat {
    private final Problem warning;

    CsvFormatWithWarning(Problem warning) {
      super();
      this.warning = warning;
    }

    @Override
    protected Class<Object> getParameterClass() {
      // otherwise ReflectionUtils fails on parent class
      return Object.class;
    }

    @Override
    protected ResultOrProblems<? extends RiskscapeWriter> newWriter(ExecutionContext context, Struct type,
        CreateHandle handle, Object bound) {

      return ResultOrProblems.of(new CsvWriter(type, context.getProject().getProblemSink(), handle, false))
              .withMoreProblems(warning);
    }
  }

  @Test
  public void doesNotHideWarningsFromWriterContstructors() throws Exception {
    Problem warning = Problems.foundWith(this).withSeverity(Severity.WARNING);
    CsvFormatWithWarning format = new CsvFormatWithWarning(warning);
    try (PipelineOutputContainer container = getOutputContainer(outputBasePath.toUri()).get()) {
      writeSpatialData(container, "spatial", format);
    }
    assertThat(outputBasePath, hasFile(fileWithName("spatial.csv")));
    // Note, that FileSystemPipelineOutputStore doesn't sink this problem itself anymore. Rather we're
    // testing that it is bubbling up in the ResultOrProblems where BasePipelineOutputSotreTest is sinking it
    assertThat(sunkProblems, contains(Problems.foundWith(format, warning)));
  }
}
