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


import java.lang.reflect.Field;
import java.lang.reflect.ParameterizedType;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Collections;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import java.util.function.BiFunction;
import java.util.stream.Collectors;

import com.google.common.base.CaseFormat;
import com.google.common.base.Converter;
import com.google.common.base.Defaults;
import com.google.common.collect.Lists;
import com.google.common.primitives.Primitives;

import lombok.Getter;
import lombok.NonNull;
import lombok.RequiredArgsConstructor;
import nz.org.riskscape.ReflectionUtils;
import nz.org.riskscape.engine.RiskscapeException;
import nz.org.riskscape.problem.ResultOrProblems;
import nz.org.riskscape.util.ListUtils;


/**
 * Extends {@link ParameterSet} to work with parameter classes that use the {@link ParameterField} to declare
 * parameters.
 *
 * This class (and its friends) are in the engine project, rather than core, as it's a helper implementation, rather
 * than part of the API.
  */
public class JavaParameterSet<T> extends ParameterSet {

  /**
   * Create a {@link JavaParameterSet} from the given parameter class using the default lower hyphen
   * case format for parameter names.
   */
  public static <T> JavaParameterSet<T> fromBindingClass(Class<T> bindsTo) {
    return fromBindingClass(bindsTo, CaseFormat.LOWER_HYPHEN);
  }

  /**
   * Create a {@link JavaParameterSet} from the given parameter class and the keyFormat to use for
   * parameter names.
   */
  public static <T> JavaParameterSet<T> fromBindingClass(Class<T> bindsTo, CaseFormat keyFormat) {
    List<JavaParameter> javaParams = buildParameters(ReflectionUtils.newInstance(bindsTo),
        Collections.emptyList(), keyFormat);

    return new JavaParameterSet<>(javaParams, bindsTo);
  }

  /**
   * Create a {@link JavaParameterSet} from the given parameter class, using the supplied instance to generate parameter
   * defaults (as opposed to the defaults that come from constructing the instance).
   */
  public static <T> JavaParameterSet<T> fromBindingInstance(Class<T> bindsTo, T bindsToInstance) {
    List<JavaParameter> javaParams = buildParameters(bindsToInstance, Collections.emptyList(), CaseFormat.LOWER_HYPHEN);
    return new JavaParameterSet<>(javaParams, bindsTo);
  }

  /**
   * @return a list of {@link JavaParameter}s defined from the given parameter class instance and are named with
   * the given parameterNameFormat applied
   */
  static List<JavaParameter> buildParameters(Object instance, @NonNull List<Field> nesting,
      CaseFormat parameterNameFormat) {
    Class<?> parameterClass = instance.getClass();

    List<Field> fields = ReflectionUtils.getAnnotatedFields(parameterClass, ParameterField.class);

    int finalSize = fields.size();

    // build parameters for any parameter classes that are included
    List<Field> includedClasses = ReflectionUtils.getAnnotatedFields(parameterClass, IncludeParameters.class);
    List<List<JavaParameter>> subResults = new ArrayList<>(includedClasses.size());
    for (Field included : includedClasses) {
      included.setAccessible(true);
      Object subParameterInstance;
      try {
        subParameterInstance = included.get(instance);
      } catch (IllegalArgumentException | IllegalAccessException e) {
        throw new RuntimeException("Unable to access included parameter object - " + included, e);
      }

      // we remember the 'path' used to access the field
      List<JavaParameter> subParams = buildParameters(subParameterInstance, ListUtils.append(nesting, included),
          parameterNameFormat);
      finalSize += subParams.size();
      subResults.add(subParams);
    }

    List<JavaParameter> result = new ArrayList<>(finalSize);
    for (Field parameterField : fields) {
      result.add(new JavaParameter(
          modelParameterFromField(parameterField, instance, parameterNameFormat),
          parameterField,
          instance,
          nesting
      ));
    }

    // add in the sub param results
    for (List<JavaParameter> list : subResults) {
      result.addAll(list);
    }

    return result;
  }

  /**
   * Wraps a {@link Parameter} to add extra Java-specific information to it to make binding
   * simpler.
   */
  @RequiredArgsConstructor
  static class JavaParameter {
    @Getter
    public final Parameter parameter;
    public final Field field;
    public final Object instance;
    public final List<Field> nesting;
  }

  private static boolean isCollection(Field field) {
    ParameterField annotation = field.getAnnotation(ParameterField.class);
    // sometimes we want to bind to something that produces a Collection, but still
    // treat it as a single/scalar value, e.g. bind `[1, 2, 3]` and store it as a List
    return Collection.class.isAssignableFrom(field.getType()) && !annotation.scalarOverride();
  }

  /**
   * Construct a {@link Parameter} from a {@link Field} annotated with {@link ParameterField}
   * @param field the field to build a {@link Parameter} from, must be annotated with {@link ParameterField}
   * @param empty a default instance of the parameter class, used for slurping out default values.
   * @param caseFormat used for converting parameter names to their user-expected format
   */
  static Parameter modelParameterFromField(
      @NonNull Field field,
      @NonNull Object empty,
      @NonNull CaseFormat caseFormat) {

    Object assignedDefault;
    try {
      field.setAccessible(true);
      assignedDefault = empty == null ? null : field.get(empty);
    } catch (IllegalAccessException e) {
      throw new RuntimeException("Unable to extract a possible default value from empty parameter class instance", e);
    }

    ParameterField annotation = field.getAnnotation(ParameterField.class);
    Class<?> parameterType;
    int min, max = 1;
    boolean isCollection = isCollection(field);

    if (!isCollection && (annotation.maxRequired() != -1 || annotation.minRequired() != -1)) {
      throw new IllegalArgumentException("Non-collection fields can not specify min and max - " + field);
    }

    BiFunction<BindingContext, Parameter, List<?>> function = null;
    if (!annotation.defaultValue().equals(Parameter.NO_DEFAULT)) {
      function = (mc, mp) -> Collections.singletonList(mc.bind(mp, annotation.defaultValue()));
    } else if (assignedDefault != null) {
      // unwrap an optional - optional.empty is not a useful default and confuses later binding
      if (assignedDefault instanceof Optional) {
        Optional<?> assignedOptional = (Optional<?>) assignedDefault;
        if (assignedOptional.isPresent()) {
          function = (mp, mc) -> Collections.singletonList(assignedOptional.get());
        }
      } else if (isCollection && assignedDefault != null) {
        Collection<?> assignedCollection = (Collection<?>) assignedDefault;
        function = (mp, mc) -> Lists.newArrayList(assignedCollection);
      } else {
        function = (mp, mc) -> Collections.singletonList(assignedDefault);
      }
    }

    if (isCollection || Optional.class.isAssignableFrom(field.getType())) {
      try {
        ParameterizedType genericType = (ParameterizedType) field.getGenericType();
        parameterType = (Class<?>) genericType.getActualTypeArguments()[0];
      } catch (ClassCastException ex) {
        throw new RuntimeException(String.format(
            "API misuse - optional field %s needs to be parameterized with a single class", field));
      }

      min = 0;
      if (isCollection) {
        if (annotation.maxRequired() != -1) {
          max = annotation.maxRequired();
        } else {
          max = Integer.MAX_VALUE;
        }
        if (annotation.minRequired() != -1) {
          min= annotation.minRequired();
        } else {
          min = 0;
        }
      }
    } else {
      parameterType = Primitives.wrap(field.getType());
      min = 1;
    }

    Converter<String,String> converter = CaseFormat.LOWER_CAMEL.converterTo(caseFormat);

    return new Parameter(
      converter.convert(field.getName()),
      parameterType,
      Optional.ofNullable(function),
      min, max
    );
  }

  /**
   * The parameter class that declares a bunch of {@link ParameterField}s
   */
  @Getter
  private final Class<T> bindsTo;

  @Getter
  private final List<JavaParameter> javaParameters;

  protected JavaParameterSet(List<JavaParameter> javaParams, Class<T> bindsTo) {
    super(javaParams.stream().map(JavaParameter::getParameter).collect(Collectors.toList()));
    this.bindsTo = bindsTo;
    this.javaParameters = javaParams;
  }

  /**
   * Variation of bind that returns a {@link BoundJavaParameters} object that includes an instance of the parameter
   * class populated with bound values.
   */
  public ResultOrProblems<BoundJavaParameters<T>> bindToObject(BindingContext context, Map<String, List<?>> unbound) {
    return super.bind(context, unbound).map(bp ->
      new BoundJavaParameters<T>(bp, newInstance(bp))
    );
  }

  /**
   * Bind an existing {@link BoundParameters} object to an instance of the binding class.
   */
  public BoundJavaParameters<T> bindToObject(BoundParameters parameters) {
    if (!parameters.getBoundTo().getDeclared().equals(this.getDeclared())) {
      throw new IllegalArgumentException("given parameters are from a different parameter set");
    }

    return new BoundJavaParameters<>(parameters, newInstance(parameters));
  }

  protected T newInstance(BoundParameters p) {
    T instance = ReflectionUtils.newInstance(bindsTo);
    bindMapToObject(p.getValueMap(), instance);
    return instance;
  }

  private void bindMapToObject(Map<String, List<?>> values, T instance) {
    for (JavaParameter javaParameter : javaParameters) {
      List<?> entry = values.get(javaParameter.getParameter().getName());

      Field field = javaParameter.field;
      field.setAccessible(true);

      try {
        Object thisBindTo = instance;
        for (Field nesting : javaParameter.nesting) {
          thisBindTo = nesting.get(thisBindTo);

          if (thisBindTo == null) {
            throw new NullPointerException("Field " +  nesting + " was null for next parameter instance");
          }
        }

        if (entry == null) {
          bindMissingValueForField(field, thisBindTo);
          continue;
        }

        Object toSet;
        if (isCollection(field)) {
          // TODO if a value is already there, just add to it
          Collection<Object> toSetCollection = matchCollectionType(field.getType(), values);
          toSetCollection.addAll(entry);
          toSet = toSetCollection;
        } else {
          if (entry.size() > 0) {
            // more than one entry here is an arity error - we should already detect that
            // building the BoundParameters, so just use the last value specified here
            toSet = entry.get(entry.size() - 1);
          } else {
            toSet = null;
          }
        }

        if (Optional.class.isAssignableFrom(field.getType())) {
          toSet = Optional.ofNullable(toSet);
        } else if (field.getType().isPrimitive() && toSet == null) {
          // we can't set null to this, so set the JLS defined default value - this is only likely to happen if the
          // binding failed for this field anyway, so the actual object shouldn't get used, or the using code better be
          // defensive in its use of the bound data.  If you care, maybe you should use a wrapper type instead
          toSet = Defaults.defaultValue(field.getType());
        }

        field.set(thisBindTo, toSet);
      } catch (IllegalArgumentException | IllegalAccessException e) {
        throw new RuntimeException(String.format("Unable to set value %s to field %s", entry, field), e);
      }
    }
  }

  private void bindMissingValueForField(Field field, Object bindTo) {
    // set defaults for empty/unset collection/optional types to avoid NPEs
    Object toSet;
    if (field.getType().equals(Optional.class)) {
      toSet = Optional.empty();
    } else if (isCollection(field)) {
      Collection<?> setCollection;
      try {
        setCollection = (Collection<?>) field.get(bindTo);
      } catch (IllegalArgumentException | IllegalAccessException e) {
        throw new RuntimeException(String.format(
            "Unable to sniff collection field default for %s", field), e);
      }

      if (setCollection == null) {
        if (List.class.isAssignableFrom(field.getType())) {
          toSet = new ArrayList<>(0);
        } else if (Set.class.isAssignableFrom(field.getType())) {
          toSet = new HashSet<>(0);
        } else {
          throw new NullPointerException(
            String.format(
              "Un-bound parameter class instance has unrecognised collection type '%s'"
              + " and no empty default has been set.",
              field.getType()
          ));
        }
      } else if (!setCollection.isEmpty()) {
        throw new RiskscapeException("Un-bound parameter class instance has a non-empty collection set where no "
            + "parameter has been supplied.  This is a misuse of the binding API. Default values should be supplied to "
            + " any parameter map accepting method.");
      } else {
        // an empty collection already exists - leave it alone.
        return;
      }
    } else {
      return; //hm, nothing we can do about these, but perhaps we want to tighten this up by either setting null or
     // logging a warning?
    }

    try {
      field.set(bindTo, toSet);
    } catch (IllegalArgumentException | IllegalAccessException e) {
      throw new RuntimeException(String.format(
          "Unable to set value empty optional to field %s", field), e);
    }
  }


  private Collection<Object> matchCollectionType(Class<?> type, Map<String, List<?>> values) {
    if (type.equals(List.class)) {
      return new ArrayList<>();
    } else if (type.equals(Set.class)) {
      return new HashSet<>();
    } else if (type.equals(Collection.class)) {
      return new ArrayList<>();
    } else {
      throw new RuntimeException("unknown collection type:" + type);
    }
  }
}

