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

import java.io.File;
import java.net.URI;
import java.nio.file.Paths;
import java.util.Arrays;
import java.util.Collections;
import java.util.List;
import java.util.Locale;
import java.util.Optional;
import java.util.function.Function;
import java.util.stream.Collectors;

import org.json.simple.parser.JSONParser;
import org.json.simple.parser.ParseException;

import com.google.common.base.Strings;
import com.google.common.collect.Lists;

import lombok.Getter;
import lombok.Setter;
import nz.org.riskscape.cli.Terminal;
import nz.org.riskscape.engine.Engine;
import nz.org.riskscape.engine.Identified;
import nz.org.riskscape.engine.IdentifiedCollection;
import nz.org.riskscape.engine.IdentifiedException;
import nz.org.riskscape.engine.OsUtils;
import nz.org.riskscape.engine.Project;
import nz.org.riskscape.engine.Reference;
import nz.org.riskscape.engine.bind.BindingContext;
import nz.org.riskscape.engine.bind.BoundParameters;
import nz.org.riskscape.engine.bind.Parameter;
import nz.org.riskscape.engine.cli.Table.Property;
import nz.org.riskscape.engine.i18n.Messages;
import nz.org.riskscape.engine.pipeline.ExecutionContext;
import nz.org.riskscape.engine.resource.FileResource;
import nz.org.riskscape.engine.resource.Resource;
import nz.org.riskscape.engine.resource.ResourceLoadingException;
import nz.org.riskscape.engine.resource.StreamResource;
import nz.org.riskscape.engine.resource.StringResource;
import nz.org.riskscape.engine.rl.RealizationContext;
import nz.org.riskscape.engine.util.Pair;
import nz.org.riskscape.picocli.CommandLine.Help.Ansi;
import nz.org.riskscape.picocli.CommandLine.Help.Ansi.IStyle;
import nz.org.riskscape.picocli.CommandLine.Help.Ansi.Style;
import nz.org.riskscape.picocli.CommandLine.Help.Ansi.Text;
import nz.org.riskscape.picocli.CommandLine.Option;
import nz.org.riskscape.problem.Problem;
import nz.org.riskscape.problem.ProblemSink;
import nz.org.riskscape.problem.Problems;
import nz.org.riskscape.rl.ast.Expression;

/**
 * Base class for commands to implement if they want access to a riskscape project(and engine) during execution.
 */
public abstract class ApplicationCommand implements ChildCommand, TerminalCommand, EngineCommand {

  /**
   * Convenience method for getting an object from a collection by its id, handling any errors consistently.
   * @param collection place where objects should be fetched from
   * @param id identifier for the object
   * @param sink a place for any warnings or info (that didn't stop the object being returned) to be logged during the
   * lookup
   * @throws ExitException with a problem that describes why the collection can not return an object with that id
   */
  public static <T extends Identified> T getObject(
      IdentifiedCollection<T> collection,
      String id,
      ProblemSink sink
  ) throws ExitException {
    try {
      return collection.get(id, sink);
    } catch (IdentifiedException e) {
      throw new ExitException(e.getProblem(), e);
    }
  }

  /**
   * @return a string for use in cli output that describes the given bound parameters object
   */
  public static String paramString(BoundParameters map) {
    List<Pair<String, String>> paramValues = map.getBoundTo().getDeclared().stream()
        .map(Parameter::getName)
        .map(key -> Pair.of(key, pickValuesForDisplay(key, map)))
        .filter(pair -> pair.getRight().size() > 0)
        .map(pair -> Pair.of(pair.getLeft().toString(), pair.getRight().toString()))
        .collect(Collectors.toList());

    paramValues.sort((l, r) -> l.getLeft().compareTo(r.getLeft()));

    return combineKeyValuesForDisplay(paramValues);
  }

  /**
   * Takes a list of key-value pairs and turns them into a single padded string for display
   */
  public static String combineKeyValuesForDisplay(List<Pair<String, String>> paramValues) {
    int maxWidth = 0;
    for (Pair<String, String> pair : paramValues) {
      maxWidth = Math.max(maxWidth, pair.getLeft().length());
    }
    int finalWidth = maxWidth;

    return paramValues.stream()
        .map(pair -> Strings.padEnd(pair.getLeft(), finalWidth, ' ') + " = " + pair.getRight())
        .collect(Collectors.joining(OsUtils.LINE_SEPARATOR));
  }

  private static List<?> pickValuesForDisplay(String key, BoundParameters params) {
    List<?> boundValues = params.getValues(key);
    List<?> unboundValues = params.getUnbound().get(key);

    if (boundValues == null ||boundValues.isEmpty()) {
      Parameter param = params.getBoundTo().get(key);
      if (! param.isOptional()) {
        // The parameter is required so we want to ensure that it is displayed, otherwise the user
        // may not know that it needs to be set.
        // TODO, consider if this should apply for optional parameters to, to advertise to the user that
        //       they could use it.
        return Lists.newArrayList("");
      }
      // don't allow unbounded to be picked - no pretending there are values when there aren't
      return Collections.emptyList();
    }

    // we prefer to show how the user gave us the input, rather than what we converted it in to - this way it matches
    // what the user might recognise and remember
    List<?> picked;
    if (unboundValues == null || unboundValues.isEmpty()) {
      picked = boundValues;
    } else {
      picked = unboundValues;
    }

    return stringifyExpressions(picked);
  }

  // avoid the awful toString output of expressions
  private static <T> List<Object> stringifyExpressions(List<T> pair) {
    return pair.stream()
      .map(val -> (val instanceof Expression) ? ((Expression) val).toSource() : val)
      .collect(Collectors.toList());
  }

  @Option(names = {"-h", "--help"}, usageHelp=true)
  private boolean help;

  @Getter @Setter
  private Terminal terminal;

  @Getter @Setter
  private Messages messages;

  @Getter @Setter
  private Engine engine;

  @Getter @Setter
  private Project project;

  @Getter
  private ExecutionContext executionContext;

  public Locale getLocale() {
    return getTerminal().getLocale();
  }

  public BindingContext getBindingContext() {
    return executionContext.getBindingContext();
  }

  public RealizationContext getRealizationContext() {
    return executionContext.getRealizationContext();
  }

  @Override
  public Object run() {
    executionContext = project.newExecutionContext();
    try {
      return doCommand(project);
    } finally {
      executionContext.close();
    }
  }

  /**
   * Returns {@link Resource} from filepathOrContent that may be:
   *
   * 1) file path to file that exists, returns a {@link FileResource}
   * 2) "-", returns a {@link StreamResource} wrapping the standard in stream
   * 3) everything else returns a {@link StringResource} containing the argument's value
   *
   * @param filepathOrContent to read
   * @return read resource
   */
  protected Resource resourceFrom(String filepathOrContent) {
    try {
      File file = new File(filepathOrContent.trim());
      if (file.getName().equals("-")) {
        return new StreamResource(file.toURI(), getTerminal().getIn()) {
          @Override
          public String toString() {
            return "<STDIN>";
          }
        };
      } else if (file.exists()) {
        return FileResource.load(file.toURI());
      }
      return new StringResource(file.toURI(), filepathOrContent);
    } catch (ResourceLoadingException e) {
      throw new ExitException(1, e, "Could not read argument, reason: %s%n%n"
          + "Make sure parameter is either:%n"
          + "  * A path to a file, or%n"
          + "  * '-' to read from STDIN, or%n"
          + "  * A valid string%n",
          e);
    }
  }

  protected Object parseJsonArguments(String parametersJsonOption) {
    return parseJsonArguments(resourceFrom(parametersJsonOption));
  }

  protected Object parseJsonArguments(Resource input) {
    JSONParser parser = new JSONParser();
    try {
      return parser.parse(input.getContentAsString());
    } catch (ParseException e) {
      throw new ExitException(1, e, "Could not parse json argument, reason: %s%n%n"
          + "Make sure parameter is either:%n"
          + "  * A path to a file containing a valid JSON string, or%n"
          + "  * '-' to read JSON from STDIN, or%n"
          + "  * A valid JSON string%n",
          e);
    }
  }

  /**
   * Called to implement command processing with a fully-initialized riskscape engine.
   */
  public abstract Object doCommand(Project useProject);

  /**
   * Looks up the detailed i18n help for the given object/parts. Concatenates the
   * result into paragraph-separated block of text ready for display.
   * @return resulting help wrapped in Optional (which is empty if no i18n help found)
   */
  protected Optional<String> getDetailedHelp(Object object, String... parts) {

    List<String> detailedHelp = getMessages().getDetailedHelpMessage(object, parts);
    if (detailedHelp.isEmpty()) {
      // no i18n help available - fallback to something else instead
      return Optional.empty();
    } else {
      // join the paragraphs into a single string, separated by newlines
      return Optional.of(String.format(String.join("%n %n", detailedHelp)));
    }
  }

  protected <T> Text formatProblemsForTableText(Ansi ansi, Reference<T> reference) {
    List<IStyle> styles = reference.getResult().hasErrors() ? Arrays.asList(Style.bg_red, Style.fg_white)
        : Arrays.asList(Style.bg_yellow, Style.fg_white);
    return ansi.apply(formatFailedObjectErrorsForTable(reference), styles);
  }

  protected String formatFailedObjectErrorsForTable(Reference<?> reference) {
    StringBuilder failures = new StringBuilder();
    for (Problem problem : reference.getResult().getProblems()) {
      failures.append(messages.renderProblem(problem).toString(o -> o.terminateWith = OsUtils.LINE_SEPARATOR));
    }
    return failures.toString();
  }

  /**
   * Turns an Identified object into display text for command table output. This
   * includes displaying any errors or warnings for the Identified thing.
   */
  public <T extends Identified> Text displayWithProblems(Ansi ansi, Reference<T> ref,
      Function<T, String> displayFunction) {

    if (ref.getResult().hasProblems()) {
      // show the problems associated with this function
      Text failures = formatProblemsForTableText(ansi, ref);

      // if the thing has a description, show that first before the errors
      String description = ref.getResult().map(i -> displayFunction.apply(i)).orElse("");
      if (description.length() == 0) {
        return failures;
      } else {
        return ansi.apply(description, Terminal.NO_STYLES).concat(Table.LINE_BREAK).concat(failures);
      }
    }

    return ansi.apply(displayFunction.apply(ref.getResult().get()), Terminal.NO_STYLES);
  }

  /**
   * Helper for turning an Identified Reference to a Property in a table
   */
  public <T extends Identified> Property<Reference<T>> asPropertyWithProblems(String colName,
      Function<T, String> displayFunction) {
    return Property.styled(colName, (ref, ansi) -> displayWithProblems(ansi, ref, displayFunction));
  }

  /**
   * Binds the given output string to a URI. Relative URIs are resolved relative to the current
   * working directory, rather than being relative to the project's output base.
   *
   * NB this method is intended for use with '--output' options associated with pipeline output
   *
   * @throws ExitException should any binding problems occur.
   */
  protected URI bindOutput(String output) throws ExitException {
    BindingContext ctx = executionContext.getBindingContext()
        // this ignores any project-defined output-base-location in favour of always generating urls that are relative
        // to the process working dir.
        .withNewRelativeTo(Paths.get("").toUri());
    return ctx.bind(output, URI.class)
        .orElseThrow(problems -> new ExitException(Problems.foundWith("--output", problems)));
  }

}
