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

import java.util.Collections;
import java.util.List;
import java.util.Map;

import org.locationtech.jts.geom.Geometry;
import org.locationtech.jts.operation.buffer.BufferOp;
import org.locationtech.jts.operation.buffer.BufferParameters;

import com.google.common.collect.Lists;

import lombok.Getter;
import lombok.RequiredArgsConstructor;
import nz.org.riskscape.engine.ArgsProblems;
import nz.org.riskscape.engine.SRIDSet;
import nz.org.riskscape.engine.Tuple;
import nz.org.riskscape.engine.function.ArgumentList;
import nz.org.riskscape.engine.function.BaseRealizableFunction;
import nz.org.riskscape.engine.function.FunctionArgument;
import nz.org.riskscape.engine.function.RiskscapeFunction;
import nz.org.riskscape.engine.geo.GeometryUtils;
import nz.org.riskscape.engine.problem.GeneralProblems;
import nz.org.riskscape.engine.rl.RealizationContext;
import nz.org.riskscape.engine.types.Nullable;
import nz.org.riskscape.engine.types.Referenced;
import nz.org.riskscape.engine.types.Struct;
import nz.org.riskscape.engine.types.Type;
import nz.org.riskscape.engine.types.Types;
import nz.org.riskscape.engine.types.WithinSet;
import nz.org.riskscape.engine.types.eqrule.Coercer;
import nz.org.riskscape.engine.typeset.TypeSet;
import nz.org.riskscape.problem.ProblemException;
import nz.org.riskscape.problem.Problems;
import nz.org.riskscape.problem.ResultOrProblems;
import nz.org.riskscape.rl.ast.FunctionCall;

/**
 * Function to buffer (enlarge) a {@link Geometry}. Unlike standard buffer functions this one always
 * specifies the distance (to buffer by) in metres.
 */
public class Buffer extends BaseRealizableFunction {

  public static final List<String> ALLOWED_OPTIONS = Lists.newArrayList("cap", "vertex");

  /**
   * To return a buffered {@link Geometry} using the default buffer parameters.
   */
  @RequiredArgsConstructor
  private static class BufferInstance implements RiskscapeFunction {

    @Getter
    private final List<Type> argumentTypes;

    @Getter
    private final Type returnType;

    private final Coercer geomCoercer;

    private final BufferParameters bufferParams;

    private final SRIDSet sridSet;

    @Override
    public Object call(List<Object> args) {
      Object geomArg = args.get(0);
      if (geomArg == null) {
        return null;
      }
      Geometry geom = (Geometry) geomCoercer.apply(geomArg);
      Number distance = (Number) args.get(1);

      if (geom == null || distance == null) {
        return null;
      }

      // we need to ensure the distance is converted to CRS units if necessary
      double crsDistance = GeometryUtils.distanceToCrsUnits(distance.doubleValue(), geom, sridSet);

      return BufferOp.bufferOp(geom, crsDistance, bufferParams);
    }
  }

  public Buffer() {
    super(
        ArgumentList.fromArray(
          new FunctionArgument("geom", Types.GEOMETRY),
          new FunctionArgument("distance", Types.FLOATING),
          new FunctionArgument("options", Struct.of(
              "cap", new WithinSet(Types.TEXT, "flat", "round", "square"),
              "vertex", new WithinSet(Types.TEXT, "bevel", "mitre", "round")
          ))
    ), Types.GEOMETRY);
  }

  @Override
  public ResultOrProblems<RiskscapeFunction> realize(RealizationContext context, FunctionCall fc,
      List<Type> givenTypes) {

    return ProblemException.catching(() -> {
      if (givenTypes.size() < 2 || givenTypes.size() > arguments.size()) {
        throw new ProblemException(Problems.get(ArgsProblems.class).wrongNumberRange(2, arguments.size(),
            givenTypes.size()));
      }

      // Here we have to coerce the geometry arg manually because coercion fails for the function as a whole because of
      // the options type not being coercible when options are not given.  An alternative fix would be to mark the
      // options as nullable and then each member as nullable, but that's going to get messy.  The plan is to try and
      // address some of these PITA concerns when we get to
      // https://gitlab.catalyst.net.nz/riskscape/riskscape/-/issues/114
      boolean nullableInputs = Nullable.is(givenTypes.get(0));
      Coercer geomCoercer = Coercer.identity(Types.GEOMETRY);
      TypeSet typeSet = context.getProject().getTypeSet();
      Type geomType = Nullable.strip(givenTypes.get(0));
      if (! typeSet.isAssignable(geomType, Types.GEOMETRY)) {
        // But users may give us a struct that contains a geometry to buffer. So we look for an
        // equivalence coercer else fail.
        geomCoercer = typeSet.findEquivalenceCoercer(geomType, Types.GEOMETRY)
              .orElseThrow(() -> new ProblemException(ArgsProblems.mismatch(arguments.get(0), givenTypes.get(0))));
        geomType = geomCoercer.getTargetType();
      }

      // Arg 1 is the distance
      nullableInputs = nullableInputs || Nullable.is(givenTypes.get(1));
      if (!(Nullable.strip(givenTypes.get(1)).isNumeric())) {
        // distance must be numeric
        throw new ProblemException(ArgsProblems.mismatch(arguments.get(1), givenTypes.get(1)));
      }

      // If the input geometry is referenced then the return type can have the same referenced CRS.
      // TODO consider making use of the bounds if that is available.
      Type bufferedReturnType = Nullable.ifTrue(nullableInputs, geomType.find(Referenced.class)
          .map(ref -> Referenced.ofNullable(Types.GEOMETRY, ref.getCrs()))
          .orElse(Types.GEOMETRY));

      // get options tuple
      Map<String, Object> options;
      if (givenTypes.size() == 3) {
        options =
            arguments.evaluateConstant(context, fc, "options", Tuple.class, Struct.EMPTY_STRUCT).getOrThrow().toMap();

        List<String> optionKeys = Lists.newArrayList(options.keySet());
        optionKeys.removeAll(ALLOWED_OPTIONS);
        if (! optionKeys.isEmpty()) {
          throw new ProblemException(GeneralProblems.get().notAnOption(
             optionKeys.get(0), "options", ALLOWED_OPTIONS));
        }

      } else {
        options = Collections.emptyMap();
      }

      BufferParameters bufferParams = new BufferParameters();
      updateParameters(bufferParams, options);

      return new BufferInstance(givenTypes, bufferedReturnType, geomCoercer, bufferParams,
          context.getProject().getSridSet());
    });

  }

  void updateParameters(BufferParameters parameters, Map<String, Object> options) throws ProblemException {
    Object cap = options.get("cap");
    if (cap != null) {
      if ("flat".equals(cap)) {
        parameters.setEndCapStyle(BufferParameters.CAP_FLAT);
      } else if ("round".equals(cap)) {
        parameters.setEndCapStyle(BufferParameters.CAP_ROUND);
      } else if ("square".equals(cap)) {
        parameters.setEndCapStyle(BufferParameters.CAP_SQUARE);
      } else {
        // Not recognised
        throw new ProblemException(GeneralProblems.get().notAnOption(
            cap.toString(), "cap", Lists.newArrayList("flat", "round", "square")));
      }
    }

    Object vertex = options.get("vertex");
    if (vertex != null) {
      if ("bevel".equals(vertex)) {
        parameters.setJoinStyle(BufferParameters.JOIN_BEVEL);
      } else if ("round".equals(vertex)) {
        parameters.setJoinStyle(BufferParameters.JOIN_ROUND);
      } else if ("mitre".equals(vertex)) {
        parameters.setJoinStyle(BufferParameters.JOIN_MITRE);
      } else {
        // Not recognised
        throw new ProblemException(GeneralProblems.get().notAnOption(
             vertex.toString(), "vertex", Lists.newArrayList("bevel", "mitre", "round")));
      }
    }

  }
}
