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

import java.io.File;
import java.io.IOException;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.Map;
import java.util.Optional;

import org.geotools.api.coverage.grid.GridCoverage;
import org.geotools.api.data.DataSourceException;
import org.geotools.api.referencing.crs.CoordinateReferenceSystem;
import org.geotools.coverage.grid.GridCoverage2D;
import org.geotools.coverage.grid.io.AbstractGridFormat;
import org.geotools.coverage.grid.io.GridCoverage2DReader;
import org.geotools.coverage.grid.io.GridFormatFinder;
import org.geotools.coverage.grid.io.UnknownFormat;
import org.geotools.gce.geotiff.GeoTiffReader;
import org.geotools.util.factory.Hints;

import com.google.common.collect.ImmutableMap;
import com.google.common.io.Files;

import lombok.extern.slf4j.Slf4j;
import nz.org.riskscape.engine.Engine;
import nz.org.riskscape.engine.FileProblems;
import nz.org.riskscape.engine.bind.BindingContext;
import nz.org.riskscape.engine.bind.JavaParameterSet;
import nz.org.riskscape.engine.bind.ParameterField;
import nz.org.riskscape.engine.coverage.TypedCoverage;
import nz.org.riskscape.engine.data.BaseBookmarkResolver;
import nz.org.riskscape.engine.data.BookmarkParameters;
import nz.org.riskscape.engine.problem.GeneralProblems;
import nz.org.riskscape.engine.rl.RealizationContext;
import nz.org.riskscape.engine.tiff.SparseTIFFImageReaderSpi;
import nz.org.riskscape.engine.tiff.SparseTiffCoverage;
import nz.org.riskscape.problem.Problem;
import nz.org.riskscape.problem.Problems;
import nz.org.riskscape.problem.ResultOrProblems;
import nz.org.riskscape.rl.ast.Expression;
import nz.org.riskscape.rl.ast.Lambda;

@Slf4j
public class CoverageFileBookmarkResolver extends BaseBookmarkResolver<CoverageFileBookmarkResolver.Params> {


  public static class Params extends BookmarkParameters {
    @ParameterField
    public Optional<String> layer = Optional.empty();

    @ParameterField
    public Optional<String> crsName = Optional.empty();

    /**
     * Default to X,Y ordering for coverages. GeoTIFF axis order is always X,Y or long,lat.
     * Refer https://docs.opengeospatial.org/is/19-008r4/19-008r4.html
     * Documentation for ARC Grid format isn't great, so it's hard to tell, but defaulting
     * to X,Y ordering would be consistent with the other ESRI file formats.
     */
    @ParameterField
    public boolean crsLongitudeFirst = true;

    @ParameterField
    public Optional<Expression> mapValue;

    public Optional<CoordinateReferenceSystem> crs = Optional.empty();

    @ParameterField
    public boolean sparseTiff = true;

    @Override
    public Class<?> getDataType() {
      return mapValue.isPresent()? MappedCoverage.class : GridTypedCoverage.class;
    }
  }

  public CoverageFileBookmarkResolver(Engine engine) {
    super(engine);
  }

  @Override
  protected JavaParameterSet<Params> buildParameterSet() {
    Params instance = new Params();
    // twiddle the default for sparse tiff, depending on whether beta is enabled or not
    if (engine.isBetaPluginEnabled()) {
      instance.sparseTiff = true;
    } else {
      instance.sparseTiff = false;
    }
    return JavaParameterSet.fromBindingInstance(getParamsClass(), instance);
  }

  @Override
  protected Map<String, String> getAliasMapping() {
    return ImmutableMap.of("crs-force-xy", "crs-longitude-first");
  }

  public static final Map<String, String> EXTENSIONS_TO_FORMATS = ImmutableMap.<String, String>builder()
      .put("asc", "arcgrid")
      .put("tif", "geotiff")
      .put("tiff", "geotiff")
      .build();

  protected static final String MEMO_FILE = "file";

  @Override
  protected Map<String, String> getExtensionsToFormats() {
    return EXTENSIONS_TO_FORMATS;
  }

  @Override
  protected ResultOrProblems<? extends Object> build(Params params) {

    GridCoverage2DReader reader = null;
    boolean isTiff;
    try {
      File file = getBookmarkedPath(params).toFile();
      String fileExtension = Files.getFileExtension(file.getName());
      if ("tif".equals(fileExtension) || "tiff".equals(fileExtension)) {

        // longitude first is required here to get the correct axis order from CRS in the geotiff metadata
        Hints hints = new Hints(Hints.FORCE_LONGITUDE_FIRST_AXIS_ORDER, Boolean.TRUE);
        params.crs.ifPresent(c -> hints.put(Hints.DEFAULT_COORDINATE_REFERENCE_SYSTEM, c));

        // Override the default TIFF reader with one that supports faster querying of no-data regions
        if (params.sparseTiff) {
          hints.put(GeoTiffReader.HINT_READER_SPI, new SparseTIFFImageReaderSpi());
        }

        // see if geotools can open the tiff file
        try {
          reader = new GeoTiffReader(file, hints);
        } catch (DataSourceException ex) {
          return ResultOrProblems.failed(
              FileProblems.get().geotoolsCannotRead(file).withChildren(Problems.caught(ex)),
              FileProblems.get().badGeospatialDataTip(file, "GeoTIFF")
          );
        }

        isTiff = true;
      } else {
        AbstractGridFormat format = findFormat(params, file);
        if (format == null || format.equals(new UnknownFormat())) {
          return ResultOrProblems.failed(Problem.error(
              "Could not open '%s' - check that the format matches the file extension",
              file));
        }
        ResultOrProblems<Hints> hints = buildHints(params);
        if (hints.hasErrors()) {
          return ResultOrProblems.failed(hints.getProblems());
        }

        reader = format.getReader(file, hints.get());
        isTiff = false;
      }

      List<String> availableLayers = Arrays.asList(reader.getGridCoverageNames());
      if (!params.layer.isPresent()) {
        params.layer = Optional.of(availableLayers.get(0));
      } else if (! availableLayers.contains(params.layer.get())) {
        return ResultOrProblems.failed(Problem.error(
            "Layer '%s' is not a known layer. Available layers are %s", params.layer.get(), availableLayers));
      }

      GridCoverage coverage = reader.read(params.layer.get(), null);
      if (isTiff && params.sparseTiff) {
        coverage = SparseTiffCoverage.createIfSupported(params.getProject(), coverage, file);
      }

      // There are a few implementation details in GridTypedCoverage that depend on GridCoverage2D (rather than just
      // GridCoverage).  We only support tiff and ascii grid, which are both GridCoverage2D, but just in case...
      if (!(coverage instanceof GridCoverage2D)) {
        return ResultOrProblems.failed(GeneralProblems.get().operationNotSupported("coverage", coverage.getClass()));
      }

      GridTypedCoverage typedCoverage =
          new GridTypedCoverage((GridCoverage2D) coverage, params.getProject().getSridSet());

      return params.mapValue
          .map(expr -> buildMappedCoverage(params.getRealizationContext(), typedCoverage, expr)
              .composeProblems(Problems.foundWith(getParameterSet().get("map-value"))))
          .orElse(ResultOrProblems.of(typedCoverage));

    } catch (Exception e) {
      return ResultOrProblems.failed(Problem.error(e, "Failed to open '%s'", params.getLocation())
              .withChildren(Problems.caught(e)));
    } finally {
      try {
        if (reader != null) {
          reader.dispose();
        }
      } catch (IOException e) {
        log.warn("Failed to dispose coverage reader", e);
      }
    }
  }

  private ResultOrProblems<TypedCoverage> buildMappedCoverage(RealizationContext context, GridTypedCoverage coverage,
      Expression expression) {
    return expression.isA(Lambda.class)
        // if user has supplied a lambda, we can make that work
        .map(lambda -> MappedCoverage.build(context, coverage, lambda))
        // it's just a regular expression, so assume it'll use the 'value' attribute
        .orElse(MappedCoverage.build(context, coverage, "value", expression));
  }

  protected ResultOrProblems<Hints> buildHints(Params params) {
    Hints hints = new Hints();
    List<Problem> problems = new ArrayList<>();
    params.crs.ifPresent(c -> hints.put(Hints.DEFAULT_COORDINATE_REFERENCE_SYSTEM, c));

    // KLUDGE: we allow the ogr/gdal plugin to load an esri prj (GL#157)
    if (!params.crs.isPresent()) {

      CoordinateReferenceSystem crs = attemptPrjParse(params.getLocation(), problems);
      if (crs != null) {
        hints.put(Hints.DEFAULT_COORDINATE_REFERENCE_SYSTEM, crs);
      }
    }

    return ResultOrProblems.of(hints, problems);
  }

  @Override
  protected void validateParameters(Params params, BindingContext context) {
    params.crsName.ifPresent(name -> {
      BaseBookmarkResolver.setCrs(params, name, params.crsLongitudeFirst, getParameterSet().get("crs-name"),
          crs -> params.crs = Optional.of(crs));
    });
  }

  protected AbstractGridFormat findFormat(Params params, File file) {
    return GridFormatFinder.findFormat(file);
  }

}
