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

import java.net.MalformedURLException;
import java.net.URL;
import java.nio.file.Path;
import java.util.Map;
import java.util.Optional;
import java.util.function.Function;

import org.geotools.api.referencing.crs.CoordinateReferenceSystem;

import com.google.common.collect.ImmutableMap;

import nz.org.riskscape.engine.Engine;
import nz.org.riskscape.engine.RiskscapeIOException;
import nz.org.riskscape.engine.bind.BindingContext;
import nz.org.riskscape.engine.bind.ParamProblems;
import nz.org.riskscape.engine.bind.ParameterField;
import nz.org.riskscape.engine.data.relation.RelationBookmarkParams;
import nz.org.riskscape.engine.data.relation.RelationBookmarkResolver;
import nz.org.riskscape.engine.projection.Projector;
import nz.org.riskscape.engine.projection.TypeProjection;
import nz.org.riskscape.engine.query.TupleUtils;
import nz.org.riskscape.engine.relation.CsvRelation;
import nz.org.riskscape.engine.relation.Relation;
import nz.org.riskscape.engine.relation.RelationBestTypeInferrer;
import nz.org.riskscape.engine.relation.SpatialMetadata;
import nz.org.riskscape.engine.types.Struct;
import nz.org.riskscape.engine.types.Types;
import nz.org.riskscape.problem.Problem;
import nz.org.riskscape.problem.ResultOrProblems;
import nz.org.riskscape.problem.Problem.Severity;
import nz.org.riskscape.rl.ast.Expression;

/**
 * Can construct a geometry-less CSV relation from a url, and has some support for creating a geometry-fied type using
 * bookmark attributes.
 *
 */
public class CsvResolver extends RelationBookmarkResolver<CsvResolver.CsvParams> {

  public static class CsvParams extends RelationBookmarkParams {
    @ParameterField
    public Optional<String> geometryAttribute = Optional.empty();

    @ParameterField
    public Optional<Expression> geometryExpression = Optional.empty();

    @ParameterField
    public boolean addLineNumbers = false;

    @ParameterField
    public boolean typeDetection = false;
  }

  public static final Map<String, String> EXTENSIONS_TO_FORMATS =
      ImmutableMap.of("csv", "csv");

  /**
   * geometry-attribute is assumed to be this if geometry-expression is given without a geometry-attribute.
   */
  private static final String DEFAULT_GEOMETRY_ATTRIBUTE = "the_geom";

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

  @Override
  protected ResultOrProblems<Relation> postBuild(Relation built, CsvParams params) {
    ResultOrProblems<Relation> asBuilt = ResultOrProblems.of(built);
    if (params.typeDetection) {
      // if type detection in on we check to see if it can improve the relation, else fall back to asBuilt.
      // we do type detection last to ensure it cannot break any existing geometry/attribute mappings
      // e.g the type inferrer will not add a geometry if one already exists.
      return RelationBestTypeInferrer
          .projectToBestTypes(built, params.getRealizationContext())
          .orElse(asBuilt);
    }
    return asBuilt;
  }

  @Override
  protected ResultOrProblems<Relation> createRawRelationFromBookmark(CsvParams params) {
    URL url;
    try {
      // you would think that we could create a csv from an input stream. but CsvRelation does a read
      // twice. up front to infer the data's type, then again to read the data. so we need to fetch the
      // file if it is remote.
      ResultOrProblems<Path> csv = getBookmarkedPathOr(params);
      if (csv.hasProblems()) {
        return ResultOrProblems.failed(csv.getProblems());
      }
      url = csv.get().toUri().toURL();
    } catch (MalformedURLException e) {
      return ResultOrProblems.failed(Problem.error("Failed to open URI '%s' - %s", params.getLocation(), e));
    }

    CsvRelation fromUrl;
    try {
      fromUrl = CsvRelation.fromUrl(url, params.addLineNumbers);
    } catch (RiskscapeIOException ex) {
      return ResultOrProblems.failed(Problem.error(ex, "Failed to open csv data from %s", url));
    }

    if (params.geometryAttribute.isPresent()) {
      TypeProjection projection = buildProjection(fromUrl, params);
      return fromUrl.project(projection);
    } else {
      return ResultOrProblems.of(fromUrl);
    }
  }

  private TypeProjection buildProjection(CsvRelation fromUrl, CsvParams params) {
    String geometryAttribute = params.geometryAttribute.orElse(null);
    Expression geometryExpression = params.geometryExpression.orElse(null);
    CoordinateReferenceSystem crs = params.crs.orElse(null);

    Struct rawType = fromUrl.getProducedType();
    Struct newType;
    if (rawType.hasMember(geometryAttribute)) {
      newType = rawType.replace(geometryAttribute, Types.GEOMETRY);
    } else {
      newType = rawType.add(geometryAttribute, Types.GEOMETRY);
    }

    Map<String, Expression> attrMap;
    if (geometryExpression != null) {
      attrMap = ImmutableMap.of(geometryAttribute, geometryExpression);
    } else {
      attrMap = ImmutableMap.of();
    }
    SpatialMetadata spatialMetadata = new SpatialMetadata(crs, TupleUtils.findRequiredGeometryMember(newType));

    // extend default type projection to fix projected spatial metadata - otherwise we get none, and models complain.
    TypeProjection projection = new TypeProjection(newType, attrMap, params.getRealizationContext()) {
      @Override
      public ResultOrProblems<Projector> getProjectionFunction(Struct sourceType) {
        return buildMemberList(sourceType).map(mapping -> {
          return new TypeProjection.TypeProjector(sourceType, mapping.targetType, mapping.memberList) {
            @Override
            public Optional<Function<SpatialMetadata, SpatialMetadata>> getSpatialMetadataMapper() {
              return Optional.of((ignored) -> {
                return spatialMetadata;
              });
            }
          };
        });
      }
    };

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

  @Override
  protected void validateParameters(CsvParams params, BindingContext context) {
    super.validateParameters(params, null);

    if (params.geometryExpression.isPresent() && !params.geometryAttribute.isPresent()) {
      params.geometryAttribute = Optional.of(DEFAULT_GEOMETRY_ATTRIBUTE);
    }

    if (params.geometryAttribute.isPresent() && !params.crs.isPresent()) {
      params.crs = Optional.of(params.getProject().getDefaultCrs());
      params.add(ParamProblems.get().applyingDefault("crs-name", params.getProject().getDefaultCrs())
          .withSeverity(Severity.WARNING));
    }

    if (params.typeDetection && params.type.isPresent()) {
      params.add(ParamProblems.get().mutuallyExclusive("type", "type-detection"));
    }
  }

}
