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

import java.io.File;
import java.io.FileInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.net.URI;
import java.nio.channels.FileChannel;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.Enumeration;
import java.util.HashMap;
import java.util.HashSet;
import java.util.LinkedList;
import java.util.List;
import java.util.Map;
import java.util.Map.Entry;
import java.util.Optional;
import java.util.Set;
import java.util.function.Consumer;
import java.util.regex.Pattern;
import java.util.stream.Collectors;
import java.util.zip.ZipEntry;
import java.util.zip.ZipFile;

import org.geotools.api.referencing.FactoryException;
import org.geotools.api.referencing.NoSuchAuthorityCodeException;
import org.geotools.api.referencing.crs.CoordinateReferenceSystem;
import org.geotools.data.PrjFileReader;
import org.geotools.referencing.CRS;

import com.google.common.base.Strings;
import com.google.common.collect.Sets;
import com.google.common.collect.Sets.SetView;

import lombok.Getter;
import lombok.NonNull;
import lombok.extern.slf4j.Slf4j;
import nz.org.riskscape.ReflectionUtils;
import nz.org.riskscape.engine.Engine;
import nz.org.riskscape.engine.FileProblems;
import nz.org.riskscape.engine.GeometryProblems;
import nz.org.riskscape.engine.RiskscapeIOException;
import nz.org.riskscape.engine.auth.HttpSecret;
import nz.org.riskscape.engine.auth.Secret;
import nz.org.riskscape.engine.auth.SecretBuilders;
import nz.org.riskscape.engine.auth.SecretProblems;
import nz.org.riskscape.engine.auth.Secrets;
import nz.org.riskscape.engine.bind.BindingContext;
import nz.org.riskscape.engine.bind.BoundJavaParameters;
import nz.org.riskscape.engine.bind.BoundParameters;
import nz.org.riskscape.engine.bind.JavaParameterSet;
import nz.org.riskscape.engine.bind.ParamProblems;
import nz.org.riskscape.engine.bind.Parameter;
import nz.org.riskscape.engine.geo.PrjParser;
import nz.org.riskscape.engine.pipeline.ExecutionContext;
import nz.org.riskscape.engine.resource.Resource;
import nz.org.riskscape.engine.resource.Resource.Options;
import nz.org.riskscape.engine.resource.ResourceFactory;
import nz.org.riskscape.engine.resource.UriHelper;
import nz.org.riskscape.problem.Problem;
import nz.org.riskscape.problem.Problem.Severity;
import nz.org.riskscape.problem.ProblemCode;
import nz.org.riskscape.problem.Problems;
import nz.org.riskscape.problem.ResultOrProblems;

/**
 * Base implementation of a {@link BookmarkResolver} that takes care of matching a format or file extension and
 * parameter validation from the {@link Bookmark} before handing over to a sub type to do the actual construction.
 */
@Slf4j
public abstract class BaseBookmarkResolver<T extends BookmarkParameters> implements BookmarkResolver {

  public enum ProblemCodes implements ProblemCode {
    USER_DEFINED_CRS_ERROR,
    UNSUPPORTED_PRJ_TIP,
  }

  public static void setCrs(BookmarkParameters params, String crsName, boolean longitudeFirst,
      Parameter param,
      Consumer<CoordinateReferenceSystem> crs) {
    try {
      crs.accept(CRS.decode(crsName, longitudeFirst));
    } catch (NoSuchAuthorityCodeException e) {
      // this exception means it was a bad CRS code
      params.problems.add(GeometryProblems.get().unknownCrsCode(crsName));
    } catch (FactoryException e) {
      params.problems.add(Problem.error(ProblemCodes.USER_DEFINED_CRS_ERROR, crsName, e.getMessage()).affecting(param));
    }
  }

  public BaseBookmarkResolver(Engine engine) {
    this.engine = engine;
    parameterSet = buildParameterSet();
  }

  @Getter
  private final JavaParameterSet<T> parameterSet;

  @Getter
  protected final Engine engine;

  @Override
  public Optional<ResolvedBookmark> resolve(Bookmark bookmark, ExecutionContext context) {
    String format = getFormat(bookmark);

    if (format == null || !getFormats().contains(format)) {
      return Optional.empty();
    }

    T paramsObject = bindAndValidate(bookmark, context);
    return Optional.of(newResolved(paramsObject));
  }

  /**
   * @return the format either defined in the bookmark or inferred from other bookmark options.  Can be null if none
   * is recognised.
   */
  public String getFormat(Bookmark bookmark) {
    URI uri = bookmark.getLocation();
    String format = bookmark.getFormat();
    if (Strings.isNullOrEmpty(format)) {
      String file = uri.getPath();
      if (file == null) {
        return format;
      }
      for (String extension : getExtensionsToFormats().keySet()) {
        if (file.endsWith("." + extension)) {
          format = getExtensionsToFormats().get(extension);
        }
      }
    }
    return format;
  }

  /**
   * Return a {@link ResolvedBookmark} for the given parameters.
   * @param parameters a bound, but not necessarily valid, parameters object
   * @return a {@link ResolvedBookmark} for the given bookmark, given that we've checked the format is supported
   */
  protected ResolvedBookmark newResolved(T parameters) {
    if (parameters.bookmark == null) {
      throw new NullPointerException("parameters.bookmark can not be null");
    }
    if (parameters.bindingContext == null) {
      throw new NullPointerException("parameters.bindingContext can not be null");
    }
    return new ResolvedBookmarkImpl<>(this, parameters);
  }

  /**
   * @return a {@link Map} of file extensions that apply for specific formats supported by this
   * {@link BookmarkResolver}
   */
  protected abstract Map<String, String> getExtensionsToFormats();

  /**
   * @return the set of formats this {@link BookmarkResolver} supports.  Defaults to returning the values from
   * {@link BaseBookmarkResolver#getExtensionsToFormats()}.
   */
  @Override
  public Set<String> getFormats() {
    return new HashSet<>(getExtensionsToFormats().values());
  }

  @Override
  public List<String> getExtensions(String format) {
    List<String> extensions = new ArrayList<>();
    for (Map.Entry<String, String> entry : getExtensionsToFormats().entrySet()) {
      if (format.equals(entry.getValue())) {
        extensions.add(entry.getKey());
      }
    }
    return extensions;
  }

  /**
   * Validates the given bookmark against this resolver, checking the various options that have been supplied in the
   * bookmark.  This is meant to be a lightweight operation - defer expensive or I/O inducing operations to the
   * {@link #build(BookmarkParameters)} method.
   * @return a {@link BookmarkParameters} including any cached constructed objects and problems encountered.  If any of
   * the
   * problems are errors, then {@link #build(BookmarkParameters)} won't be called.
   */
  protected final T bindAndValidate(Bookmark bookmark, ExecutionContext context) {

    List<Problem> problems = new LinkedList<>();
    Map<String, List<?>> unparsed = bookmark.getParamMap();
    Map<String, List<?>> mapped = findMapishOptions(unparsed, problems);
    mapped = convertAliases(mapped, problems);

    BoundParameters bound = parameterSet.bind(context.getBindingContext(), mapped);
    BoundJavaParameters<T> boundObj = parameterSet.bindToObject(bound);
    T paramsObject = boundObj.getBoundToObject();

    paramsObject.bookmark = bookmark;
    paramsObject.executionContext = context;
    paramsObject.bindingContext = context.getBindingContext();
    paramsObject.problems.addAll(bound.getValidationProblems());
    paramsObject.problems.addAll(problems);

    findUnusedParameters(paramsObject, mapped, context.getBindingContext());

    applyFile(paramsObject);

    validateParameters(paramsObject, context.getBindingContext());

    validateCommonParameters(paramsObject, context.getBindingContext());

    return paramsObject;
  }

  private void applyFile(T paramsObject) {
    URI location = paramsObject.getLocation();
    if ("file".equals(location.getScheme())) {
      File file = new File(location.getPath());
      checkFile(paramsObject, file);
      paramsObject.validatedFile = Optional.of(file);
    }
  }

  private void findUnusedParameters(T memo, Map<String, ?> unparsed, BindingContext context) {
    Set<String> expectedNames = parameterSet.getDeclared().stream().map(Parameter::getName).collect(Collectors.toSet());
    Set<String> givenNames = unparsed.keySet();

    SetView<String> difference = Sets.difference(givenNames, expectedNames);

    if (!difference.isEmpty()) {
      memo.problems.add(Problem.warning("Unrecognized parameters given - %s - these will be ignored", difference));
    }
  }

  /**
   * Slightly kludgey map-munger that looks for options that are split with a point and turns them in to a map
   * e.g.
   * ```
   * option.foo = cool
   * option.bar = story
   * ```
   * Gets converted to
   * ```
   * option = {"foo":"cool", "bar": "story"}
   * ```
   *
   * This allows these options to be parsed and validated as a single defined parameter.
   */
  Map<String, List<?>> findMapishOptions(Map<String, List<?>> unparsed, List<Problem> problems) {
    Map<String, List<?>> withMaps = new HashMap<>();

    for (Map.Entry<String, List<?>> entry : unparsed.entrySet()) {
      String key = entry.getKey();
      List<?> values = entry.getValue();

      String[] splitKey = key.split(Pattern.quote("."), 2);
      if (splitKey.length == 2) {
        String keyPrefix = splitKey[0];
        String keySuffix = splitKey[1];

        @SuppressWarnings("unchecked")
        Map<String, Object> nestedMap = (Map<String, Object>) withMaps.computeIfAbsent(keyPrefix, k -> {
          return Arrays.asList(new HashMap<String, Object>());
        }).get(0);

        // tres awks - it's possible to have multiple values, but our code so far only expects single elements in these
        // maps.  Let's take the last one and warn the user
        if (values.size() > 1) {
          problems.add(ParamProblems.get().wrongNumberGiven(key, "1", values.size()));
        }

        nestedMap.put(keySuffix, values.get(values.size() - 1));
      } else {
        withMaps.put(key, values);
      }
    }
    return withMaps;
  }

  private Map<String, List<?>> convertAliases(Map<String, List<?>> unparsed, List<Problem> addTo) {
    Map<String, List<?>> convertedParams = new HashMap<>();
    Map<String, String> aliases = getAliasMapping();

    for (Entry<String, List<?>> userValue : unparsed.entrySet()) {
      // check if the user-supplied param name is an alias
      String newParamName = aliases.get(userValue.getKey());
      if (newParamName == null) {
        // nothing to do - use the user value as is
        convertedParams.put(userValue.getKey(), userValue.getValue());
      } else if (unparsed.containsKey(newParamName)) {
        // reject it if the user specifies both alias variants (i.e. both the old and
        // new parameter names). They should just pick one
        addTo.add(Problems.get(ParamProblems.class)
            .mutuallyExclusive(newParamName, userValue.getKey()));
      } else {
        // convert the user-supplied alias to the new parameter name
        convertedParams.put(newParamName, userValue.getValue());
      }
    }
    return convertedParams;
  }

  protected void validateCommonParameters(T parameters, BindingContext context) {
    SecretProblems secretProblems = Problems.get(SecretProblems.class);
    if (parameters.requiresSecret.isPresent()) {
      String secretsFramework = parameters.requiresSecret.get();
      // sanity-check the require-secrets framework is valid
      ResultOrProblems<?> problemsOr = engine.getCollection(SecretBuilders.class).getOr(secretsFramework);
      if (problemsOr.hasProblems()) {
        parameters.add(Problems.foundWith(getParameterSet().get("requires-secret"), problemsOr.getProblems()));
        return;
      }

      if (context.getEngine().hasCollectionOf(Secret.class)) {
        Secrets secrets = engine.getCollection(Secrets.class);
        Optional<HttpSecret> found = secrets.getOfType(HttpSecret.class).stream()
            .filter(s -> s.matches(parameters.location) && s.getFramework().equals(secretsFramework))
            .findFirst();
        if (!found.isPresent()) {
          // no secrets found
          parameters.add(
              secretProblems.requiredNotFound(secretsFramework, parameters.getLocation().getHost(),
                  secretProblems.noSecretsHint(Secrets.getUserHomeSecrets(engine))
              )
          );
        }
      }
    }
  }

  /**
   * Perform sub-class specific validation on a bound parameters object.  Any errors should be added to the
   * parameters object via {@link BookmarkParameters#add(Problem)}
   */
  protected void validateParameters(T parameters, BindingContext context) {
  }


  /**
   * @return a {@link JavaParameterSet} to use with binding bookmark parameters for this resolver.  By default, it uses
   * the parameterized type's class to build one, but this isn't always appropriate and so the behaviour can be
   * customized by overriding this method.
   */
  protected JavaParameterSet<T> buildParameterSet() {
    return JavaParameterSet.fromBindingClass(getParamsClass());
  };

  /**
   * Return the class used to store the parameters for this resolver.  Used by buildParameterSet.
   */
  protected Class<T> getParamsClass() {
    return ReflectionUtils.findParameterClass(getClass());
  }

  /**
   * Returns any alias mappings that should be applied for things like
   * legacy/deprecated parameter names as Map<aliasParam, newParamName>.
   */
  protected Map<String, String> getAliasMapping() {
    return Collections.emptyMap();
  }

  /**
   * Attempt to construct the coverage or relation that is being pointed to by this bookmark.  Will only be called
   * by the {@link ResolvedBookmarkImpl} if the memo does not include any error level problems.
   * @param parameters populated parameters object that would have been constructed by the
   * {@link #validateParameters(BookmarkParameters, BindingContext)} method.
   * @return a possible relation or coverage, including any problems encountered during construction.  Do not
   * include the validation problems - this is taken care of by the {@link ResolvedBookmarkImpl} class.
   */
  protected abstract ResultOrProblems build(T parameters);

  /**
   * Perform checks on any file pointed to by a bookmark's location
   */
  protected void checkFile(T paramsObject, File file) {
    if (!file.exists()) {
      paramsObject.add(Problem.error("File '%s' does not exist", file));
    }

    if (file.exists() && !file.canRead()) {
      paramsObject.add(Problem.error("File '%s' can not be read (do you have permission to access "
          + "this file?)", file));
    }

  }

  /**
   * Get the {@link Path} that should be resolved for the bookmark location.
   *
   * If the bookmark has a validated file, then that is used. Otherwise the
   * {@link ResourceFactory} is used to fetch the resource.
   *
   * @param paramsObject
   * @return path to
   */
  protected Path getBookmarkedPath(T paramsObject) {
    return paramsObject.getBookmarkedPath();
  }

  protected ResultOrProblems<Path> getBookmarkedPathOr(T paramsObject) {
    try {
      return ResultOrProblems.of(getBookmarkedPath(paramsObject));
    } catch (Exception e) {
      return ResultOrProblems.failed(Problems.caught(e));
    }
  }

  /**
   * Attempts parsing a prj file using available parsers.  This is here to support GL#157, so we can parse ESRI
   * prj files, especially where they don't use the WKT or WKTish format.
   *
   * Current implementation only supports file resources
   * @param location uri of the data we're loading that may or may not have a sidecar prj file
   */
  protected CoordinateReferenceSystem attemptPrjParse(URI location) {
    // parse the prj file ignoring any problems (assume we'll work around them elsewhere)
    return attemptPrjParse(location, new ArrayList<>());
  }

  /**
   * Same as {@link #attemptPrjParse(URI)} except it also returns any problems
   * that may have been encountered, e.g. prj format is not supported.
   */
  protected CoordinateReferenceSystem attemptPrjParse(URI location, List<Problem> problems) {
    if (!UriHelper.isFile(location)) {
      log.info("URI {} is not a file resource, prj parsing may fail", location);
      return null;
    }

    String pathPart = location.getPath();
    Path asPath = Paths.get(location);
    String extension = com.google.common.io.Files.getFileExtension(pathPart);
    if (!Strings.isNullOrEmpty(extension)) {
      File prj =
          asPath.getParent().resolve(com.google.common.io.Files.getNameWithoutExtension(pathPart) + ".prj").toFile();

      if (prj.exists()) {
        CoordinateReferenceSystem parsed = attemptPrjParse(prj);

        if (parsed == null && !canGeoToolsParsePrj(prj)) {
          problems.add(Problems.get(FileProblems.class).unsupportedFormat(prj)
              .withChildren(new Problem(Severity.INFO, ProblemCodes.UNSUPPORTED_PRJ_TIP)));
        }
        return parsed;
      }
    }

    return null;
  }

  /**
   * Returns true if GeoTools can parse the given .prj file (i.e. it can at least
   * open the file without any problems). Returns false if the .prj file definitely
   * can't be parsed.
   */
  private boolean canGeoToolsParsePrj(File prj) {
    try (FileChannel channel = new FileInputStream(prj).getChannel();
        PrjFileReader projReader = new PrjFileReader(channel)) {
      // if .prj file is in an unsupported format, GeoTools bails out in the
      // PrjFileReader constructor. If we got this far, the file must be OK
      return true;
    } catch (IOException | FactoryException e) {
    }
    return false;
  }

  private CoordinateReferenceSystem attemptPrjParse(File prj) {
    List<PrjParser> parsers = engine.getFeaturesOfType(PrjParser.class);

    if (parsers.size() == 0) {
      // not sure what advice to give here
      log.info("No prj parsers present, GeoTools parser will be used");
    }

    String prjContents = null;
    for (PrjParser parser : parsers) {
      if (prjContents == null) {
        try {
          List<String> lines = Files.readAllLines(prj.toPath(), java.nio.charset.Charset.forName("LATIN1"));
          StringBuilder builder = new StringBuilder();
          lines.forEach(str -> builder.append(str).append("\n"));

          prjContents = builder.toString();
        } catch (IOException e) {
          throw new RuntimeException("uh oh, latin1 is missing", e);
        }
      }

      try {
        return parser.parsePrj(prjContents);
      } catch (Throwable e) {
        log.warn("Failed to parse prj using {}", parser, e);
      }
    }

    return null;
  }

  /**
   * Converts a resource to a {@link Path} by saving the content of {@link Resource#getResourceStream() } to a
   * local file.
   *
   * Additionally it will inspect the {@link Resource#getSource() } for filenames ending with '.zip' in which case
   * the following handling in applied:
   * - Resource is un-zipped
   * - Looks for a file with the same name as resource filename, after '.zip' is removed
   * - Return that path e.g https://riskscape.org.nz/data/assets.shp.zip would be a zip bundle containing assets.shp
   *   and associatiated files.
   *
   * @param resource to make available locally
   * @return path to file created locally containing resource content
   * @throws RiskscapeIOException
   */
  static Path resourceToPath(Resource resource, ExecutionContext context) throws RiskscapeIOException {
    try {
      // NB might end up downloading the file many many times, even though we might have already downloaded once in this
      // context.  I'm
      // not 100% it'll happen in practice, but we could set the parent dir based on the bookmark id or something and
      // then if it already exists, just return the path to the existing thing (rather than downloading it again).
      String targetFilename = localFilename(resource.getLocation());

      Options options = new Options();
      options.tempDirectory = Optional.of(context.getTempDirectory());

      Path localPath = resource
          .ensureLocal(options)
          .orElseThrow((probs) -> new RiskscapeIOException(Problems.toSingleProblem(probs)));

      if (localPath.toString().endsWith(".zip")) {
        //Looks like a zip file.
        Path tempDirectory = Files.createTempDirectory(context.getTempDirectory(), "rs-resource");
        try (ZipFile zip = new ZipFile(localPath.toFile())) {
          for (Enumeration<? extends ZipEntry> entries = zip.entries(); entries.hasMoreElements();) {
            ZipEntry ze = entries.nextElement();
            try (InputStream is = zip.getInputStream(ze)) {
              Files.copy(is, tempDirectory.resolve(ze.getName()));
            }
          }
          //Look for target filename
          Path targetInZip = tempDirectory.resolve(targetFilename.substring(0, targetFilename.length()
              - ".zip".length()));
          if (targetInZip.toFile().exists()) {
            localPath = targetInZip;
          }
        } catch (IOException e) {
          //Error whilse unzipping. Maybe it's filename is misleading. Ignore and carry on.
        }
      }
      return localPath;
    } catch (IOException e) {
      throw new RiskscapeIOException("Could not access " + resource.getLocation().toString(), e);
    }
  }

  /**
   * Attempts to get a filename from to {@link URI} to allow the local file to have the same name.
   * Of course this may not be possible for all URIs in which case a default name should be returned.
   *
   * @param uri to get filename from
   * @return filename or default
   */
  static String localFilename(@NonNull URI uri) {
    String path = uri.getPath();
    if (path != null) {
      if (!path.endsWith("/")) {
        int lastSlash = path.lastIndexOf("/");
        if (lastSlash > -1) {
          return path.substring(lastSlash + 1);
        }
        return path;
      }
    }
    return "resource";
  }

}
