/*
 * 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.net.URI;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.Optional;

import com.google.common.collect.ImmutableList;

import lombok.Getter;
import lombok.Setter;
import nz.org.riskscape.config.ConfigString;
import nz.org.riskscape.engine.Engine;
import nz.org.riskscape.engine.Project;
import nz.org.riskscape.engine.SRIDSet;
import nz.org.riskscape.engine.bind.impl.ArgumentListBinder;
import nz.org.riskscape.engine.bind.impl.BookmarkBinder;
import nz.org.riskscape.engine.bind.impl.BooleanBinder;
import nz.org.riskscape.engine.bind.impl.ConfigStringBinder;
import nz.org.riskscape.engine.bind.impl.CoordinateReferenceSystemBinder;
import nz.org.riskscape.engine.bind.impl.EnumBinder;
import nz.org.riskscape.engine.bind.impl.EnvelopeBinder;
import nz.org.riskscape.engine.bind.impl.ExpressionBinder;
import nz.org.riskscape.engine.bind.impl.FormatBinder;
import nz.org.riskscape.engine.bind.impl.FunctionBinder;
import nz.org.riskscape.engine.bind.impl.FunctionCallPrototypeBinder;
import nz.org.riskscape.engine.bind.impl.IdentityBinder;
import nz.org.riskscape.engine.bind.impl.NumberBinder;
import nz.org.riskscape.engine.bind.impl.SortByBinder;
import nz.org.riskscape.engine.bind.impl.StringBinder;
import nz.org.riskscape.engine.bind.impl.TypeBinder;
import nz.org.riskscape.engine.bind.impl.UriBinder;
import nz.org.riskscape.engine.data.relation.AttributeMapBinder;
import nz.org.riskscape.problem.ProblemSink;
import nz.org.riskscape.problem.ResultOrProblems;

/**
 * Various bits of dependency that might be required for parameter binding
 */
public class DefaultBindingContext implements BindingContext {

  public static final List<ParameterBinder> DEFAULT_BINDERS = ImmutableList.of(new FunctionBinder(),
      new BooleanBinder(),
      new EnumBinder(),
      new BookmarkBinder(),
      new TypeBinder(),
      new NumberBinder(),
      new EnvelopeBinder(),
      new CoordinateReferenceSystemBinder(),
      new AttributeMapBinder(),
      new SortByBinder(),
      new ExpressionBinder(),
      new FunctionCallPrototypeBinder(),
      new UriBinder(),
      new StringBinder(),
      new ArgumentListBinder(),
      new FormatBinder(),
      new IdentityBinder(),
      new ConfigStringBinder()
  );

  private List<ParameterBinder> binders;

  @Getter @Setter
  private SRIDSet sridSet;

  @Getter @Setter
  private Project project;

  /**
   * URI that any relative file paths should be resolved against.
   */
  @Getter
  private final URI relativeTo;

  private final Path tempDir;

  @Getter
  private final ProblemSink problemSink;

  private DefaultBindingContext(List<ParameterBinder> binders, SRIDSet sridSet, Project project,
      URI relativeTo, Path tempDir, ProblemSink problemSink) {
    // wrap it in a mutable list to support insertion of custom binders
    this.binders = new ArrayList<>(binders);
    this.sridSet = sridSet;
    this.project = project;
    this.relativeTo = relativeTo;
    this.tempDir = tempDir;
    this.problemSink = problemSink;
  }

  public DefaultBindingContext(Project project, ProblemSink problemSink) {
    this(project, null, problemSink);
  }

  private DefaultBindingContext(Project project, Path tempDir, ProblemSink problemSink) {
    this(project.getEngine().getBinders().getAll(),
        project.getSridSet(),
        project,
        project.getRelativeTo(),
        tempDir,
        problemSink);
  }

  /**
   * Get a new BindingContext with relativeTo Uri set to newRelativeTo.
   * @param newRelativeTo new base URI to resolve relative files against
   * @return binding context with relative to set to newRelativeTo
   */
  @Override
  public DefaultBindingContext withNewRelativeTo(URI newRelativeTo) {
    return new DefaultBindingContext(binders, sridSet, project, newRelativeTo, tempDir, problemSink);
  }

  /**
   * @return a {@link ParameterBinder} for the given java type in modelParameter.
   */
  @Override
  public Optional<ParameterBinder> getBinder(Class<?> fromType, Class<?> toType) {
    return binders.stream()
        .filter(pt -> pt.canBind(fromType, toType))
        .findFirst();
  }

  private ParameterBinder getReqdBinder(Class<?> fromType, Class<?> toType) {
    return getBinder(fromType, toType).orElseThrow(() -> new NoBindingAvailableException(fromType, toType));
  }

  /**
   * Bind the given value to the required type
   * @return a bound value, or a failed result describing why the value couldn't be converted
   * @throws NoBindingAvailableException if there wasn't a binder available.  This is thrown, rather than returned in
   * result or problems as this is a system error rather than a user error
   */
  @Override
  public <T> ResultOrProblems<T> bind(Object value, Class<T> expectedType) {
    Object normalizedValue = normalizeValue(value, expectedType);
    return getReqdBinder(normalizedValue.getClass(), expectedType).bindValue(this, normalizedValue, expectedType);
  }

  // compatibility fix for ConfigString: Many of our binders have been implemented to expect strings, but we are now
  // passing ConfigString in to binding (which can't extend String).  The ConfigString is useful, cos it has source
  // metadata, but in this case we want to remove it before we bind the value.
  //
  // I thought about adding this to the ConfigStringBinder, but canBind doesn't have access to the bindingContext, so
  // we can't do the recursive lookup required.  Ah well...
  private Object normalizeValue(Object value, Class<?> expectedType) {

    if (value instanceof ConfigString cs) {
      // defensive: we shouldn't be called if the expected type matches the value's type
      if (expectedType != ConfigString.class) {
        return cs.toString();
      }
    }

    return value;
  }

  @Override @Deprecated
  public Object bind(Parameter param, String value) {
    ParameterBinder binder = getBinder(String.class, param.getType())
        .orElseThrow(() -> new UnbindableParameterException(param, "No binder available for type %s", param.getType()));

    return binder.bind(this, param, value);
  }

  /**
   * Validates a map of already bound parameters against a models set of declared parameters to check
   * their arity and type.
   * @throws InvalidBoundParametersException if there are problems with number or type of a parameter
   */
  @Override
  public void validateBoundParameters(List<Parameter> parameters, Map<String, List<?>> boundParameters)
      throws InvalidBoundParametersException {

    for (Parameter declared : parameters) {
      List<?> supplied = boundParameters.getOrDefault(declared.getName(), Collections.emptyList());

      if (supplied == null) {
        throw new NullPointerException("bound parameter map should never map to a null list! null mapped key was "
            + declared.getName());
      }

      if (!declared.hasCorrectArity(supplied.size())) {
        throw new InvalidBoundParametersException(String.format(
            "Parameter '%s' is missing values, has %d, needs %d",
            declared.getName(),
            supplied.size(),
            declared.getMinRequired()));
      }

      for (Object param : supplied) {
        if (param == null) {
          throw new InvalidBoundParametersException(String.format(
              "Null value supplied for parameter '%s'",param));

        }

        if (!declared.getType().isInstance(param)) {
          throw new InvalidBoundParametersException(String.format(
              "Value '%s' for parameter '%s' is of wrong type.  Was %s, should have been %s",
              param,
              declared,
              param.getClass(),
              declared.getType()));

        }
      }
    }
  }

  @Override
  public Engine getEngine() {
    return project.getEngine();
  }

  @Override
  public Path getTempDirectory() {
    if (tempDir == null) {
      // if temp dir is null we defer the engine
      return getEngine().getTempDirectory();
    }
    return tempDir;
  }

  @Override
  public void addBinder(ParameterBinder binder) {
    binders.add(0, binder);
  }
}
