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

import java.util.ArrayList;
import java.util.Collection;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.LinkedList;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.function.Consumer;
import java.util.stream.Collectors;

import org.apache.commons.text.similarity.CosineSimilarity;

import com.google.common.collect.ImmutableList;
import com.google.common.reflect.TypeToken;

import lombok.EqualsAndHashCode;
import lombok.Getter;
import lombok.NonNull;
import lombok.RequiredArgsConstructor;
import lombok.ToString;
import nz.org.riskscape.engine.resource.Resource;
import nz.org.riskscape.engine.util.Pair;
import nz.org.riskscape.problem.Problem;
import nz.org.riskscape.problem.ProblemSink;
import nz.org.riskscape.problem.ResultOrProblems;

/**
 * A collection of {@link Identified} objects.  Most implementations should use the {@link Base} class provided - the
 * interface is here to avoid repetition of these core methods when building interfaces that provide access to
 * {@link Engine} and {@link Project} objects.
 *
 * @param <T> actual type of object
 */
public interface IdentifiedCollection<T extends Identified> {

  double SIMILARITY_THRESHOLD = 0.5D;
  int SIMILARITY_MAX_OPTIONS = 6;

  private static Map<CharSequence, Integer> countNgramFrequency(String sequence, int degree) {
    Map<CharSequence, Integer> m = new HashMap<>();
    sequence = sequence.toLowerCase();
    for (int i = 0; i + degree <= sequence.length(); i++) {
        String gram = sequence.substring(i, i + degree);
        m.put(gram, 1 + (m.containsKey(gram) ? m.get(gram) : 0));
    }
    return m;
  }

  static List<String> getSimilar(String given, Collection<String> available) {
    List<Pair<String, Double>> scoredAlternatives = new ArrayList<>(available.size());
    CosineSimilarity similarity = new CosineSimilarity();
    Map<CharSequence, Integer> leftVector = countNgramFrequency(given, 2);

    for (String alternative : available) {
      Map<CharSequence, Integer> rightVector = countNgramFrequency(alternative, 2);
      double score = similarity.cosineSimilarity(leftVector, rightVector);
      scoredAlternatives.add(Pair.of(alternative, score));
    }

    scoredAlternatives.sort((lhs, rhs) -> rhs.getRight().compareTo(lhs.getRight()));

    List<String> alternatives = scoredAlternatives.stream()
        .filter(pair -> pair.getRight() > SIMILARITY_THRESHOLD)
        .map(Pair::getLeft).collect(Collectors.toList());

    return alternatives.subList(0, Math.min(SIMILARITY_MAX_OPTIONS, alternatives.size()));
  }

  /**
   * Add a pre-constructed identified object to the collection.
   * @param identified object to add
   * @throws ObjectAlreadyExistsException if an object already exists with this id
   */
  void add(T identified) throws ObjectAlreadyExistsException;

  /**
   * Same as {@link #add(nz.org.riskscape.engine.Identified) } but with specified source.
   */
  void add(T identified, Resource source) throws ObjectAlreadyExistsException;

  /**
   * Add a potentially lazily constructed identified object to this collection.
   * @param identifier uniquely identifies the to be constructed object
   * @param source a Resource that is linked to the original object - may be {@link Resource#UNKNOWN}
   * @param callback a callback that will return the object - expect that the {@link IdentifiedCollection} will
   * cache the result of this call
   * @throws ObjectAlreadyExistsException if an object with the same identifier already exists in this collection
   */
  void add(String identifier, Resource source, ConstructionCallback<T> callback)
      throws ObjectAlreadyExistsException;

  /**
   * Add all of the given objects to this collection, returning a list of any that were replaced
   */
  void addAll(Collection<T> identifieds) throws ObjectAlreadyExistsException;

  /**
   * Same as {@link #addAll(java.util.Collection) where each item of the collection can be assigned the
   * same source.
   */
  void addAll(Collection<T> identifieds, Resource source) throws ObjectAlreadyExistsException;

  /**
   * @return all items in the collection ordered by id, excluding failed things
   *         Note that any problems will be ignored.
   */
  List<T> getAll();

  /**
   * @return all items in the collection ordered by id
   */
  List<Reference<T>> getReferences();

  /**
   * Get an object from the collection that has the given id.
   * @param id of item to get.
   * @return item
   * @throws NoSuchObjectException if no item with that id has been registered
   * @throws FailedObjectException if an item was registered, but it failed to be built.
   * @deprecated use {@link #get(String, ProblemSink)} instead to make sure any warnings get picked up
   */
  @Deprecated
  default T get(String id) throws NoSuchObjectException, FailedObjectException {
    return get(id, null);
  }

  /**
   * Get an object from the collection that has the given id.
   * @param id of item to get.
   * @param problemSink a place for any warnings or info problems to be sent during lookup/construction
   * @return item
   * @throws NoSuchObjectException if no item with that id has been registered
   * @throws FailedObjectException if an item was registered, but it failed to be built.
   */
  T get(String id, ProblemSink problemSink) throws NoSuchObjectException, FailedObjectException;

  /**
   * ResultOrProblems returning alternative to get, which will return the same problems that a
   * {@link NoSuchObjectException} or a {@link FailedObjectException} would have, but as part of a ResultOrProblems.
   * Also adds any warnings that were encountered in to the result.
   */
  ResultOrProblems<T> getOr(String id);

  /**
   * @return the class from this collections type signature.   E.g. if this is `Foo extends IdentifiedCollection<Bar>`
   * then getCollectionClass() will return a class instance for Bar.
   *
   * This method will fail if sub classes of {@link Base} do not declare a type for T
   * parameters have been set.
   */
  Class<? extends Identified> getCollectionClass();

  /**
   * Alternative to get that returns the result wrapped in an {@link Optional} and a {@link ResultOrProblems}
   * instead of throwing {@link IdentifiedException}s.
   */
  Optional<ResultOrProblems<T>> getResult(String id);

  /**
   * @return A list of all the problems associated with any failed items in this collection.
   */
  List<Pair<String, List<Problem>>> getAllProblems();

  /**
   * @return true if collection contains an item with matching id
   */
  boolean containsKey(String id);

  /**
   * Removes an object from the collection
   * @return true if there was something in the collection with this id, failed or otherwise
   */
  boolean remove(String id);

  /**
   * Remove everything from the collection
   */
  void clear();

  /**
   * @return true of this collection contains no entries, failed or otherwise
   */
  boolean isEmpty();

  /**
   * Find possible matches for a failed id lookup.
   * @param candidate the id that doesn't exist in this collection
   * @return a List of ids that might be what the user was looking for
   */
  List<String> getSimilarIds(String candidate);

  /**
   * Convenience class for any {@link IdentifiedCollection}s that want to work from an in-memory set of objects.
   */
  class Base<T extends Identified> implements IdentifiedCollection<T> {

    @Getter
    private final Class<T> collectionClass;

    public Base() {
      this.collectionClass = determineCollectionClass();

      if (collectionClass.equals(Identified.class)) {
        throw new AssertionError("Identified classes must be declared with a specific type");
      }
    }

    public Base(Class<T> collectionClass) {
      this.collectionClass = collectionClass;
    }

    @RequiredArgsConstructor
    @ToString
    @EqualsAndHashCode(of = "id")
    protected class Handle implements Reference<T> {
      @Getter
      private final String id;
      @Getter
      private final Resource resource;
      @Getter
      private final ConstructionCallback<T> callback;
      private ResultOrProblems<T> result;

      public boolean isPresent() {
        return getResult().isPresent();
      }

      @Override
      public ResultOrProblems<T> getResult() {
        // NB mutex?  Not required while we access things in a single threaded way,
        if (result == null) {
          result = callback.create();
        }

        return result;
      }

      @Override
      public Class<T> getIdentifiedClass() {
        return Base.this.collectionClass;
      }

      @Override
      public T get() {
        if (isFailed()) {
          throw new FailedObjectException(id, getIdentifiedClass(), getProblems());
        } else {
          return getResult().get();
        }
      }

      public T getWithProblemsIgnored() {
        return getResult().getWithProblemsIgnored();
      }

      @Override
      public void drainWarnings(Consumer<Problem> problemConsumer) {
        if (result != null) {
          result = result.drainWarnings(problemConsumer);
        }
      }

      public boolean isFailed() {
        return getResult().hasErrors();
      }

      public List<Problem> getProblems() {
        return getResult().getProblems();
      }
    }

    // uses a linked hash map to maintain insertion order - this can be important where insertion order of elements is
    // important
    protected final Map<String, Handle> results = new LinkedHashMap<>();

    @Override
    public void add(String identifier, Resource resource, ConstructionCallback<T> builder) {
      Handle existing = this.results.putIfAbsent(identifier, new Handle(identifier, resource, builder));

      if (existing != null) {
        Identified existingThing;
        if (!existing.isPresent()) {

          // Shortcut for a failed thing to make the exception happy - let's hope they don't try and use it :)
          existingThing = new Identified() {

            @Override
            public String getId() {
              return identifier;
            }

            @Override
            public Class<? extends Identified> getIdentifiedClass() {
              return Base.this.getCollectionClass();
            }
          };
        } else {
          existingThing = existing.get();
        }

        throw new ObjectAlreadyExistsException(existingThing, existing.getResource(), resource);
      }
    }

    @Override
    public boolean remove(String id) {
      return results.remove(id) != null;
    }

    @Override
    public void add(@NonNull T identified) {
      add(identified, Resource.UNKNOWN);
    }

    @Override
    public void add(@NonNull T identified, @NonNull Resource source) {
      this.add(identified.getId(), source, () -> ResultOrProblems.of(identified));
    }

    @Override
    public void addAll(@NonNull Collection<T> identifieds) {
      identifieds.forEach(i -> add(i));
    }

    @Override
    public void addAll(Collection<T> identifieds, Resource source) throws ObjectAlreadyExistsException {
      identifieds.forEach(i -> add(i, source));
    }

    @Override
    public List<T> getAll() {
      // TODO memoize this?
      return ImmutableList.copyOf(this.results.values().stream()
          .filter(Handle::isPresent)
          .map(Handle::getWithProblemsIgnored)
          .collect(Collectors.toList()));
    }

    @Override
    public List<Pair<String, List<Problem>>> getAllProblems() {
      List<Pair<String, List<Problem>>> all = new ArrayList<>(results.size() / 2);

      for (Handle result : results.values()) {
        if (result.isFailed()) {
          all.add(Pair.of(result.id, result.getResult().getProblems()));
        }
      }

      return all;
    }

    @Override
    public List<Reference<T>> getReferences() {
      return ImmutableList.copyOf(results.values());
    }

    @Override
    public T get(@NonNull String id, ProblemSink problemSink) {
      Handle handle = this.results.get(id);

      if (handle == null) {
        throw new NoSuchObjectException(id, this);
      }

      if (handle.isFailed()) {
        // don't include the resource if it's effectively a null
        if (handle.resource == Resource.UNKNOWN) {
          throw new FailedObjectException(id, collectionClass, handle.getProblems());
        } else {
          throw new FailedObjectException(id, collectionClass, handle.resource.getLocation(), handle.getProblems());
        }
      }

      if (problemSink != null) {
        handle.drainWarnings(problemSink);
      }

      return handle.get();
    }

    @Override
    public ResultOrProblems<T> getOr(@NonNull String id) {
      try {
        LinkedList<Problem> collectedWarnings = new LinkedList<>();
        return ResultOrProblems.of(get(id, p -> collectedWarnings.add(p)), collectedWarnings);
      } catch (FailedObjectException | NoSuchObjectException e) {
        return ResultOrProblems.failed(e.getProblem());
      }
    }

    @SuppressWarnings("unchecked")
    public Class<T> determineCollectionClass() {
      Class<?> rawType = TypeToken.
          of(getClass()).resolveType(IdentifiedCollection.Base.class.getTypeParameters()[0]).getRawType();

      return (Class<T>) rawType;
    }

    @Override
    public Optional<ResultOrProblems<T>> getResult(@NonNull String id) {
      // TODO call get() and return result/or based on the exceptions so we get consistent behaviour
      Handle result = this.results.get(id);

      return Optional.ofNullable(result).map(Handle::getResult);
    }

    @Override
    public boolean containsKey(@NonNull String id) {
      return results.containsKey(id);
    }

    @Override
    public boolean isEmpty() {
      return results.isEmpty();
    }

    @Override
    public void clear() {
      results.clear();
    }

    protected void removeAll(@NonNull Collection<T> items) {
      for(T item: items) {
        results.remove(item.getId());
      }
    }

    @Override
    public List<String> getSimilarIds(String candidate) {
      return getSimilar(candidate, results.keySet());
    }
  }
}
