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

import java.net.URI;
import java.util.Arrays;
import java.util.Collection;
import java.util.List;
import java.util.Locale;
import java.util.Optional;
import java.util.function.Function;
import java.util.stream.Collectors;

import org.geotools.api.referencing.crs.CoordinateReferenceSystem;
import org.geotools.geometry.jts.ReferencedEnvelope;
import org.ini4j.Profile.Section;

import com.google.common.base.CaseFormat;
import com.google.common.collect.Range;

import lombok.Data;
import lombok.RequiredArgsConstructor;
import nz.org.riskscape.config.ini.IniConfigSection;
import nz.org.riskscape.dsl.SourceLocation;
import nz.org.riskscape.dsl.Token;
import nz.org.riskscape.engine.Identified;
import nz.org.riskscape.engine.auth.HttpSecret;
import nz.org.riskscape.engine.bind.Parameter;
import nz.org.riskscape.engine.bind.ParameterProperty;
import nz.org.riskscape.engine.data.Bookmark;
import nz.org.riskscape.engine.function.FunctionArgument;
import nz.org.riskscape.engine.function.IdentifiedFunction;
import nz.org.riskscape.engine.geo.GeometryRenderer;
import nz.org.riskscape.engine.model.Model;
import nz.org.riskscape.engine.pipeline.RealizedStep;
import nz.org.riskscape.engine.problem.ProblemPlaceholder;
import nz.org.riskscape.engine.resource.Resource;
import nz.org.riskscape.engine.types.Struct.StructMember;
import nz.org.riskscape.rl.ast.PipelineDeclaration;
import nz.org.riskscape.rl.ast.StepDefinition;
import nz.org.riskscape.rl.ast.StepReference;
import nz.org.riskscape.problem.Problem;
import nz.org.riskscape.rl.ast.Expression;
import nz.org.riskscape.rl.ast.FunctionCall;
import nz.org.riskscape.rl.ast.StructDeclaration;

/**
 * Helper to render {@link Problem}s for display to users, i.e. converting the
 * i18n code to a user-friendly string.
 */
public class DefaultObjectRenderer implements ObjectRenderer {

  private interface RenderFunction<T> {
    // let's keep the 3rd arg for now - even though these are always private, we might want to make this a public thing
    // one day and it will keep the churn down
    String apply(T object, Messages messages, Locale locale);
  }

  @RequiredArgsConstructor
  private static class StringRenderer<T> implements ObjectRenderer {
    private final Class<T> forClass;
    private final RenderFunction<T> renderer;

    @Override
    public Optional<String> render(Messages messages, Object object, Locale locale) {
      if (object != null && forClass.isAssignableFrom(object.getClass())) {
        return Optional.of(renderer.apply(forClass.cast(object), messages, locale));
      } else {
        return Optional.empty();
      }
    }
  }

  /**
   * Renders a specific object (T). This checks if it can display the object based
   * on class, then converts it to an i18n MessageKey that it can lookup and
   * return as a translated String.
   */
  @RequiredArgsConstructor
  private class MessageKeyRenderer<T> implements ObjectRenderer {
    private final Class<T> forClass;
    private final Function<T, MessageKey> converter;

    public boolean canDisplay(Object object) {
      return object != null && forClass.isAssignableFrom(object.getClass());
    }

    @Override
    public Optional<String> render(Messages messages, Object object, Locale locale) {
      if (canDisplay(object)) {
        MessageKey key = converter.apply(forClass.cast(object));
        return Optional.of(messages.getProblems().getMessage(key, locale));
      } else {
        return Optional.empty();
      }
    }
  }

  private final List<ObjectRenderer> renderers;

  public DefaultObjectRenderer() {
    this.renderers = buildRenderers();
  }

  /**
   * Renders a RiskScape object into a user-friendly, translatable String format,
   * suitable for output.
   */
  public String render(Messages messages, Object object) {
    return render(messages, object, Locale.getDefault()).orElse(null);
  }

  @Override
  public Optional<String> render(Messages messages, Object object, Locale locale) {
    // if the object is a list (or similar) we want to render each item individually
    if (object instanceof Collection) {
      return Optional.of(renderCollection(messages, (Collection<?>) object, locale));
    }

    // try to find something that can display this object in a user-friendly way
    for (ObjectRenderer renderer : renderers) {
      Optional<String> rendered = renderer.render(messages, object, locale);
      if (rendered.isPresent()) {
        return rendered;
      }
    }

    // none found, default to using (null-safe) toString()
    return Optional.of(String.valueOf(object));
  }

  private String renderCollection(Messages messages, Collection<?> collection, Locale locale) {
    return collection.stream()
        .flatMap(item -> render(messages, item, locale).stream())
        .collect(Collectors.joining(", ", "[", "]"));
  }

  private List<ObjectRenderer> buildRenderers() {

    /*
     * FIXME: there's a ton of things in here that are labels that are stored in problems.properties, purely because
     * the previous Renderer implementation didn't allow message lookups.  It should be entirely possible now to keep
     * them in labels, not that it matters too much.
     */

    // this list is ordered with the most-specific match first.
    // Any plugin Renderers should be added first, before the built-in engine Renderers
    return Arrays.asList(
        // use the "nice" display name if we've got an instance of a class
      new MessageKeyRenderer<Class>(Class.class, clazz -> new MessageKeyImpl(
          clazz.getCanonicalName() + ".class",
          getDisplayName(clazz),
          new Object[0])),

      // first, just add a no-op so we don't try to unnecessarily convert
      // anything that's already a MessageKey
      new MessageKeyRenderer<MessageKey>(MessageKey.class, key -> key),

      new MessageKeyRenderer<ProblemPlaceholder>(ProblemPlaceholder.class, id -> problemPlaceholderToKey(id)),

      new MessageKeyRenderer<Bookmark>(Bookmark.class, bookmark -> bookmarkToKey(bookmark)),

      new MessageKeyRenderer<Parameter>(Parameter.class, param -> namedClassToKey(Parameter.class, param.getName())),

      new StringRenderer<HttpSecret>(HttpSecret.class, (secret, messages, locale) -> {
        if (secret.getDefinedIn() == Resource.UNKNOWN_URI)  {
          return messages.getProblems().getMessage(
              toSubMessageKey(HttpSecret.class, "noLocation", "''{0}'' {1} secret",
                  secret.getId(), secret.getFramework())
          );
        } else {
          return messages.getProblems().getMessage(
              toSubMessageKey(HttpSecret.class, "withLocation", "''{0}'' {1} secret from {2}",
                  secret.getId(), secret.getFramework(), formatUri(messages, secret.getDefinedIn(), locale))

          );
        }
      }),

      new MessageKeyRenderer<IdentifiedFunction>(IdentifiedFunction.class, function -> functionToKey(function)),

      // any Identified object should be able to be rendered easy enough
      new MessageKeyRenderer<Identified>(Identified.class, id -> namedClassToKey(id.getIdentifiedClass(), id.getId())),

      // we want pipeline step problems to refer back to the pipeline AST that caused the problem
      new MessageKeyRenderer<StepDefinition>(StepDefinition.class, step ->
        toMessageKey(StepDefinition.class, "{0}", getSourceFragment(step))),
      new MessageKeyRenderer<RealizedStep>(RealizedStep.class, step ->
        toMessageKey(StepDefinition.class, "{0}", getSourceFragment(step.getAst()))),

      new MessageKeyRenderer<StepReference>(StepReference.class,
          step -> namedClassToKey(StepReference.class, step.getIdent())),

      new MessageKeyRenderer<Model>(Model.class,
          model -> toMessageKey(Model.class, "{0} model", model.getFramework().getId())),

      // just display the Resource's URI by default
      new StringRenderer<Resource>(
          Resource.class,
          (resource, messages, locale) -> formatUri(messages, resource.getLocation(), locale)
      ),

      new StringRenderer<URI>(URI.class, (uri, messages, locale) -> formatUri(messages, uri, locale)),

      new MessageKeyRenderer<Token>(Token.class, token -> tokenToKey(token)),

      new MessageKeyRenderer<ReferencedEnvelope>(ReferencedEnvelope.class,
          env -> toMessageKey(ReferencedEnvelope.class, GeometryRenderer.getBounds(env))),

      // when rendering a CRS as a label, just use the short EPSG code
      // rather than displaying the whole crs.toString()
      new MessageKeyRenderer<CoordinateReferenceSystem>(CoordinateReferenceSystem.class,
          crs -> toMessageKey(CoordinateReferenceSystem.class, GeometryRenderer.getCode(crs))),

      new MessageKeyRenderer<FunctionArgument>(FunctionArgument.class, arg -> functionArgToKey(arg)),

      new MessageKeyRenderer<FunctionCall.Argument>(FunctionCall.Argument.class,
          arg -> namedClassToKey(FunctionCall.Argument.class, arg.toSource())),

      new MessageKeyRenderer<StructMember>(StructMember.class,
          member -> toMessageKey(member.getClass(), "''{0}: {1}'' struct member", member.getKey(), member.getType())),

      new MessageKeyRenderer<PipelineDeclaration>(PipelineDeclaration.class,
          decl -> namedClassToKey(PipelineDeclaration.class, decl.toSource())),

      new MessageKeyRenderer<Expression>(Expression.class, expr -> namedClassToKey(Expression.class, expr.toSource())),

      new MessageKeyRenderer<StructDeclaration.Member>(StructDeclaration.Member.class,
          ad -> toMessageKey(StructDeclaration.Member.class, ad.toSource(), ad.toSource())),

      new MessageKeyRenderer<Section>(Section.class, section -> namedClassToKey(Section.class, section.getName())),

      new StringRenderer<IniConfigSection>(IniConfigSection.class,
          (section, messages, locale) -> {
            String uri = formatUri(messages, section.getLocation(), locale);

            return messages.getProblems().getMessage(
                IniConfigSection.class.getCanonicalName(),
                new Object[] {section.getName(), uri},
                "INI file section ''[{0}]'' on {1}",
                locale
            );
        }),

      new MessageKeyRenderer<Range>(Range.class, range -> toMessageKey(Range.class, displayRange(range))),

      new MessageKeyRenderer<ParameterProperty>(ParameterProperty.class,
          property -> namedClassToKey(ParameterProperty.class, property.getKeyword())),

      // catch all - check if required?
      (ObjectRenderer) (object, messages, locale) -> {
        return Optional.empty();
      }
    );
  }

  private Object getSourceFragment(StepDefinition ast) {
    // start with the step implementation, adding the user assigned name if it's given
    String stepIdentity = "`" + ast.getIdent() + "` step";
    if (ast.getName().isPresent()) {
      stepIdentity = stepIdentity + " (" + ast.getName().get() + ")";
    }

    // sometimes code is 'disconnected' from the source - don't show a confusing negative line number in this case
    // FIXME we can use StringRenderer to internationalize this
    if (ast.getIdentToken().getLocation().getLine() > 0) {
      return stepIdentity + " on line " + ast.getIdentToken().getLocation().getLine();
    } else {
      return stepIdentity;
    }
  }

  private String displayRange(Range<?> range) {
    if (!range.hasLowerBound()) {
      return range.toString();
    } else if (range.hasUpperBound()) {
      if (range.lowerEndpoint().equals(range.upperEndpoint())) {
        return range.lowerEndpoint().toString();
      } else {
        return range.lowerEndpoint() + "-" + range.upperEndpoint();
      }
    } else {
      return range.lowerEndpoint() + "+";
    }
  }

  /**
   * Formats a URI for display, dropping the `file://` part and converting slashes to be os appropriate
   * TODO reuse this method in the CLI code, too, so that we don't print difficult to copy URIs to the terminal
   */
  private String formatUri(Messages messages, URI uri, Locale locale) {
    SourceLocation loc = SourceLocation.parseUriFragment(uri.getFragment()).orElse(null);

    String uriAsString;
    if ("file".equals(uri.getScheme())) {
      // using getPath instead of toPath().toString in case w there is a url fragment (which blows up)
      uriAsString = uri.getPath();
    } else {
      uriAsString = uri.toString();

      if (loc != null) {
        // remove source location from anchor fragment - we will display it differently
        uriAsString = uriAsString.substring(0, uriAsString.indexOf('#'));
      }
    }

    if (loc == null) {
      return uriAsString;
    } else if (loc.isUnlined()) {
      return messages.getProblems().getMessage(
          URI.class.getCanonicalName() + ".unlined",
          new Object[] {uriAsString, loc.getIndex() + 1}, "{0}", locale);
    } else {
      return messages.getProblems().getMessage(
          URI.class.getCanonicalName() + ".lined",
          new Object[] {uriAsString, loc.getLine()}, "{0}", locale);
    }
  }

  /**
   * Takes a stab at displaying a class as something that's meaningful to the
   * user. This kicks in for the default display when no suitable i18n code could
   * be found.
   */
  private static String getDisplayName(Class<?> clazz) {
    String className = clazz.getSimpleName();
    if (className.isEmpty()) {
      // note that simple name is blank for anonymous classes, so return the last part of the full class name
      className = clazz.getName();
      className = className.substring(className.lastIndexOf(".") + 1);
    }
    // e.g. convert ClassifierFunction.class -> "classifier function"
    return CaseFormat.UPPER_CAMEL.to(CaseFormat.LOWER_UNDERSCORE, className).replace("_", " ");
  }

  // helper to create custom MessageKey object instances easily
  @Data
  private class MessageKeyImpl implements MessageKey {
    private final String code;
    private final String defaultMessage;
    private final Object[] messageArguments;
  }

  private MessageKey toMessageKey(Class<?> clazz, String defaultMessage, Object... args) {
    return new MessageKeyImpl(clazz.getCanonicalName(), defaultMessage, args);
  }

  /**
   * Similar to {@link #toSubMessageKey(java.lang.Class, java.lang.String, java.lang.String, java.lang.Object...) }
   * except message key is `<canonical class name>.<subMessageKey>`.
   */
  private MessageKey toSubMessageKey(Class<?> clazz, String subMessageKey, String defaultMessage, Object... args) {
    return new MessageKeyImpl(clazz.getCanonicalName() + "." + subMessageKey, defaultMessage, args);
  }

  private MessageKey namedClassToKey(Class<?> clazz, String name) {
    return toMessageKey(clazz,
        String.format("''{0}'' %s", getDisplayName(clazz)),
        name);
  }

  private MessageKey problemPlaceholderToKey(ProblemPlaceholder thing) {
    // if the class being wrapped is Identified, then use the root Identified class
    Class<?> classToRender = thing.getWrappedClass();
    if (Identified.class.isAssignableFrom(classToRender)) {
      classToRender = thing.getIdentifiedClass();
    }
    return namedClassToKey(classToRender, thing.getId());
  }

  /**
   * Display a {@link Token}'s details in a user-friendly, translatable way, e.g.
   * "'xyz' statement on line 14 (column 1)"
   */
  private MessageKey tokenToKey(Token token) {
    if (token == Token.UNKNOWN_LOCATION) {
      return new MessageKeyImpl(token.getClass().getCanonicalName() + ".UNKNOWN_LOCATION",
          "<unknown location>", null);
    }

    if (token.isSourceSingleLine()) {
      return new MessageKeyImpl(
          // don't bother showing line and column information for expressions contained on a single line,
          // it's just noise
          token.getClass().getCanonicalName() + ".singleLine",
          "''{0}''",
          new Object[] {token.getValue()}
      );
    } else {
      return toMessageKey(token.getClass(),
          "''{0}'' on line {1} (column {2})",
          token.getValue(), token.getLocation().getLine(), token.getLocation().getColumn());
    }
  }

  private MessageKey functionArgToKey(FunctionArgument arg) {
    if (arg.hasKeyword()) {
      // args has a keyword - display it as "'<keyword>' function argument"
      return namedClassToKey(arg.getClass(), arg.getKeyword());
    } else {
      // use the index of args, i.e. display it as "function argument n"
      // (convert from zero-based index to one-based for the user)
      return new MessageKeyImpl(arg.getClass().getCanonicalName() + ".numbered",
          "function argument {0}", new Object[] {arg.getIndex() + 1});
    }
  }

  private MessageKey bookmarkToKey(Bookmark bookmark) {
    if (bookmark.isFromURI() || bookmark.getLocation() == null) {
      // no location or it's the same as the ID. Just display as: '<id>' bookmark
      return namedClassToKey(Bookmark.class, bookmark.getId());
    } else {
      // include the location as well, for clarity
      return new MessageKeyImpl(Bookmark.class.getName() + ".location",
          "''{0}'' bookmark in location {1}",
          new Object[] {bookmark.getId(), bookmark.getLocation()});
    }
  }

  private MessageKey functionToKey(IdentifiedFunction function) {
    if (function.getSourceURI() == Resource.UNKNOWN_URI || function.isBuiltin()) {
      // no source or it's a built-in function. Just display as: '<id>' function
      return namedClassToKey(IdentifiedFunction.class, function.getId());
    } else {
      // include the source file as well, for clarity
      return new MessageKeyImpl(IdentifiedFunction.class.getName() + ".withSource",
          "''{0}'' function (from source {1})", new Object[] {function.getId(), function.getSource()});
    }
  }
}
