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

import java.net.URI;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.LinkedList;
import java.util.List;
import java.util.Map;
import java.util.Optional;

import com.google.common.collect.Range;

import nz.org.riskscape.engine.Engine;
import nz.org.riskscape.engine.Tuple;
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.bind.ParameterSet;
import nz.org.riskscape.engine.bind.impl.BookmarkBinder;
import nz.org.riskscape.engine.coverage.TypedCoverage;
import nz.org.riskscape.engine.data.Bookmark;
import nz.org.riskscape.engine.data.BookmarkFactory;
import nz.org.riskscape.engine.data.BookmarkProblems;
import nz.org.riskscape.engine.data.ResolvedBookmark;
import nz.org.riskscape.engine.i18n.RiskscapeMessage;
import nz.org.riskscape.engine.pipeline.ExecutionContext;
import nz.org.riskscape.engine.pipeline.RealizationInput;
import nz.org.riskscape.engine.pipeline.Realized;
import nz.org.riskscape.engine.pipeline.RealizedPipeline;
import nz.org.riskscape.engine.pipeline.RealizedStep;
import nz.org.riskscape.engine.pipeline.Step;
import nz.org.riskscape.engine.projection.NestMembersProjection;
import nz.org.riskscape.engine.relation.IteratorRelation;
import nz.org.riskscape.engine.relation.PeekingTupleIterator;
import nz.org.riskscape.engine.relation.Relation;
import nz.org.riskscape.engine.rl.RealizedExpression;
import nz.org.riskscape.engine.types.Nullable;
import nz.org.riskscape.engine.types.Struct;
import nz.org.riskscape.engine.types.Text;
import nz.org.riskscape.engine.types.Type;
import nz.org.riskscape.engine.types.WithMetadata;
import nz.org.riskscape.problem.Problem;
import nz.org.riskscape.problem.ProblemException;
import nz.org.riskscape.problem.Problems;
import nz.org.riskscape.problem.ResultOrProblems;
import nz.org.riskscape.rl.TokenTypes;
import nz.org.riskscape.rl.ast.Expression;

public class RelationInputStep extends BaseStep<RelationInputStep.Parameters> {

  public static class Parameters {

    @ParameterField
    public Optional<Expression> relation;

    @ParameterField
    public Optional<String> name;

    @ParameterField()
    public Optional<Long> limit = Optional.empty();

    @ParameterField
    public Optional<Long> offset = Optional.empty();

    @ParameterField
    public Optional<Expression> value;

    public RealizationInput input;
  }

  public static Step canned(Relation relation) {
    return new Step() {

      @Override
      public String getId() {
        return "no-id";
      }

      @Override
      public RealizedPipeline realize(RealizationInput input) {
        return input.getRealizedPipeline().add(
            input.newPrototypeStep().realizedBy(this).withResult(relation)
        );
      }

      @Override
      public Range<Integer> getInputArity() {
        return null;
      }

      @Override
      public RiskscapeMessage getDescription() {
        return null;
      }

      @Override
      public ParameterSet getParameterSet() {
        return ParameterSet.EMPTY;
      }
    };
  }

  public static RealizedStep realized(Relation relation) {
    return RealizedStep.named("input").withResult(relation);
  }

  public static RealizedStep realized(Relation relation, String nestName) {
    return realized(relation.project(new NestMembersProjection(nestName)).get());
  }

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

  @Override
  public ResultOrProblems<? extends Realized> realize(Parameters parameters) {
    return ProblemException.catching(probs -> realizeThrowing(parameters, probs));
  }

  public Realized realizeThrowing(Parameters parameters, List<Problem> warnings) throws ProblemException {

    Expression relationExpr = parameters.relation.orElse(null);
    Expression constantValue = parameters.value.orElse(null);

    if (relationExpr != null) {
      // relation should either be a text constant or a bookmark() expression
      String relationToBind = relationExpr.toSource();

      // sanity-check the relation expression must always be a valid constant expression
      RealizedExpression realized = parameters.input.getRealizationContext().getExpressionRealizer()
          .realizeConstant(relationExpr).getOrThrow();

      if (realized.getResultType().isA(Text.class)) {
        // evaluate the text expression in case it's something like: $dir + '/' + $file
        relationToBind = realized.evaluateConstant().toString();
      }

      // use the old BookmarkBinder code to turn this into a Relation. This handles evaluating
      // bookmark() expressions, converting coverages to relations, etc

      Relation relation = resolve(parameters.input.getExecutionContext(), relationToBind)
          .drainWarnings(warnings::add)
          .getOrThrow(Problems.foundWith("relation"));

      if (constantValue != null) {
        throw new ProblemException(ParamProblems.get().mutuallyExclusive("relation", "value"));
      }

      if (parameters.limit.isPresent()) {
        relation = relation.limit(parameters.limit.get());
      }

      if (parameters.offset.isPresent()) {
        relation = relation.offset(parameters.offset.get());
      }

      if (parameters.name.isPresent()) {
        return relation.project(new NestMembersProjection(parameters.name.get()))
            .drainWarnings(warnings::add)
            .getOrThrow();
      } else {
        return relation;
      }
    } else {

      if (constantValue == null) {
        throw new ProblemException(ParamProblems.oneOfTheseRequired("relation", "value"));
      }

      RealizedExpression realized = parameters.input.getRealizationContext().
          getExpressionRealizer()
          .realizeConstant(constantValue)
          .drainWarnings(warnings::add)
          .getOrThrow(Problems.foundWith("value"));

      // these two are ignored, warn
      if (parameters.limit.isPresent()) {
        warnings.add(ParamProblems.get().ignored("limit"));
      }
      if (parameters.offset.isPresent()) {
        warnings.add(ParamProblems.get().ignored("offset"));
      }

      // here we either wrap a struct with a name, or if it's a non-struct value, we use the given name
      // instead of the standard `value` name we use when converting a non-struct type to a struct
      String name = parameters.name.orElse(null);
      Type notNullType = Nullable.strip(realized.getResultType());
      Struct type;
      if (name != null) {
        type = Struct.of(name, notNullType);
      } else {
        type = notNullType.asStruct();
      }

      return new IteratorRelation(type, () ->
        new PeekingTupleIterator() {

          boolean consumed = false;

          @Override
          protected String getSource() {
            return constantValue.toSource();
          }

          @Override
          protected Tuple get() {
            if (consumed) {
              return null;
            }
            consumed = true;
            Object result = realized.evaluate(Tuple.EMPTY_TUPLE);
            if (result != null) {
              if (name != null) {
                return Tuple.ofValues(type, result);
              } else {
                if (result instanceof Tuple) {
                  return (Tuple) result;
                } else {
                  return Tuple.ofValues(type, result);
                }
              }
            } else {
              return null;
            }
          }
        }
      );
    }
  }

  @Override
  public String getId() {
    return "input";
  }

  // cut down version of the old BookmarkBinder method when it did resolving.  This could probably be cleaned up a bit
  // now and better 'integrated' with how the input step works, or maybe it could just re-write any text to be a
  // bookmark function call for consistency...
  private ResultOrProblems<Relation> resolve(ExecutionContext executionContext, String rawValue) {
    String value = TokenTypes.stripQuotes(rawValue);

    BindingContext context = executionContext.getBindingContext();

    ResultOrProblems<Bookmark> lookupResult = context.getProject().getBookmarks().getResult(value).orElse(null);

    if (lookupResult != null && lookupResult.hasErrors()) {
      // it exists, but it's borked, better give the user the bad news
      return ResultOrProblems.failed(lookupResult.getProblems());
    }

    Bookmark bookmark;

    if (lookupResult == null) {
      // check in case it's a bookmark('foo', ...) expression
      bookmark = getBookmarkFromExpression(executionContext, value).orElse(null);
    } else {
      bookmark = lookupResult.get();
    }

    if (bookmark == null) {
      // this might instead be a location, try that...
      URI tryThis = BookmarkFactory.uriFromLocation(value, context.getRelativeTo()).orElse(null);

      if (tryThis == null) {
        // this typically only happens if the value couldn't be turned in to a URI, in which case we give up
        return ResultOrProblems.failed(BookmarkProblems.get().notBookmarkOrFile(value));
      } else {
        // we now have a bookmark built from a URI, let's try and resolve it...
        bookmark = Bookmark.fromURI(tryThis, value);
      }
    }

    boolean keepWrapped;
    Class<?> requiredType;
    if (ResolvedBookmark.class.isAssignableFrom(Relation.class)) {
      keepWrapped = true;
      requiredType = Object.class;
    } else {
      keepWrapped = false;
      requiredType = Relation.class;
    }

    // accept any type for now - we are open to some jujzing, so validate everything but the type - we will check the
    // requiredType matches further down the road
    ResultOrProblems<ResolvedBookmark> resolvedOr =
        context.getEngine().getBookmarkResolvers().resolveAndValidate(bookmark, executionContext, Object.class);

    // don't unwrap it - return it as is, warts and all
    if (keepWrapped) {
      // call getData() purely to reveal any problems this bookmark might have. This means
      // binding to a ResolvedBookmark produces consistent warnings with binding to a Relation
      List<Problem> problems = new ArrayList<>();
      resolvedOr.ifPresent(rb -> problems.addAll(rb.getData(rb.getDataType()).getProblems()));

      return resolvedOr.map(rb -> (Relation) rb).withMoreProblems(problems);
    }

    // kludge - try and convert a typed coverage to a relation

    List<Problem> extraProblems = new LinkedList<>();
    TypedCoverage coverage = resolvedOr.flatMap(rb -> rb.getData(TypedCoverage.class)).orElse(null);
    if (requiredType.equals(Relation.class) && coverage != null) {
      Relation relation = coverage.asRelation().orElse(null);

      if (relation != null) {
        return ResultOrProblems.of(relation);
      } else {
        // let the user know we tried
        extraProblems.add(BookmarkBinder.PROBLEMS.couldNotConvert());
      }
    }

    return resolvedOr.flatMap(rb -> rb.getData(Relation.class).withMoreProblems(extraProblems));
  }

  /**
   * Tries to realize the given value as a riskscape expression. If it's a valid
   * bookmark() expression, then we're able to pluck out the bookmark metatdata
   * that was used in the realization
   *
   * @return bookmark produced by the given expression, or Optional.empty() if
   *         it's not a valid bookmark expression
   */
  private Optional<Bookmark> getBookmarkFromExpression(ExecutionContext context, String value) {
    return context.getRealizationContext().realizeConstant(value)
      .map(realized -> WithMetadata.find(realized.getResultType(), Bookmark.class))
      .orElse(Optional.empty());
  }
  /**
   * Set a parameter for a step of this type to be a particular relation.  This is a sort of safe/convenient way
   * of pickling parameters in the case of something like nested pipelines where the input needs to be passed
   * programmatically.
   */
  public static void setInput(Relation relation, String stepName, Map<String, List<?>> pipelineParameters) {
    pipelineParameters.put(stepName + ".relation", Arrays.asList(relation));
  }
}
