// Copyright 2017 The Nomulus Authors. All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
//     http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

package google.registry.model.tld.label;

import static com.google.common.base.Preconditions.checkArgument;
import static com.google.common.base.Preconditions.checkNotNull;
import static com.google.common.base.Strings.isNullOrEmpty;
import static com.google.common.collect.ImmutableMap.toImmutableMap;
import static com.google.common.collect.ImmutableSet.toImmutableSet;
import static google.registry.config.RegistryConfig.getDomainLabelListCacheDuration;
import static google.registry.model.tld.label.ReservationType.FULLY_BLOCKED;
import static google.registry.persistence.transaction.QueryComposer.Comparator.EQ;
import static google.registry.persistence.transaction.TransactionManagerFactory.tm;

import com.github.benmanes.caffeine.cache.LoadingCache;
import com.google.common.base.Splitter;
import com.google.common.base.Stopwatch;
import com.google.common.collect.ImmutableMap;
import com.google.common.collect.ImmutableSet;
import com.google.common.util.concurrent.UncheckedExecutionException;
import google.registry.model.Buildable;
import google.registry.model.CacheUtils;
import google.registry.model.tld.Tld;
import google.registry.model.tld.label.DomainLabelMetrics.MetricsReservedListMatch;
import google.registry.persistence.EntityCallbacksListener.RecursivePostPersist;
import google.registry.persistence.EntityCallbacksListener.RecursivePreRemove;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.Id;
import jakarta.persistence.Index;
import jakarta.persistence.Table;
import jakarta.persistence.Transient;
import java.io.Serializable;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import javax.annotation.Nullable;

/**
 * A list of reserved domain labels that are blocked from being registered for various reasons.
 *
 * <p>Note that the primary key of this entity is {@link #revisionId}, which is auto-generated by
 * the database. So, if a retry of insertion happens after the previous attempt unexpectedly
 * succeeds, we will end up with having two exact same reserved lists that differ only by
 * revisionId. This is fine though, because we only use the list with the highest revisionId.
 */
@Entity
@Table(indexes = {@Index(columnList = "name", name = "reservedlist_name_idx")})
public final class ReservedList
    extends BaseDomainLabelList<ReservationType, ReservedList.ReservedListEntry> {

  /**
   * Mapping from domain label to its reserved list info.
   *
   * <p>This field requires special treatment since we want to lazy load it. We have to remove it
   * from the immutability contract so we can modify it after construction and we have to handle the
   * database processing on our own so we can detach it after load.
   */
  @Insignificant @Transient volatile ImmutableMap<String, ReservedListEntry> reservedListMap;

  @RecursivePreRemove
  void preRemove() {
    tm().query("DELETE FROM ReservedEntry WHERE revisionId = :revisionId")
        .setParameter("revisionId", revisionId)
        .executeUpdate();
  }

  /**
   * Hibernate hook called on the insert of a new ReservedList. Stores the associated {@link
   * ReservedListEntry}'s.
   *
   * <p>We need to persist the list entries, but only on the initial insert (not on update) since
   * the entries themselves never get changed, so we only annotate it with {@link
   * RecursivePostPersist}, not PostUpdate.
   */
  @RecursivePostPersist
  void postPersist() {
    if (reservedListMap != null) {
      reservedListMap
          .values()
          .forEach(
              entry -> {
                // We can safely change the revision id since it's "Insignificant".
                entry.revisionId = revisionId;
                tm().insert(entry);
              });
    }
  }

  /**
   * A reserved list entry entity, persisted to the database, that represents a single label and its
   * reservation type.
   */
  @Entity(name = "ReservedEntry")
  public static class ReservedListEntry extends DomainLabelEntry<ReservationType, ReservedListEntry>
      implements Buildable, Serializable {

    @Insignificant @Id Long revisionId;

    // This Enum field was mapped (unintended) by ordinal and is an int column in the real schema.
    // In Hibernate 6, ordinal enum field is mapped to smallint instead of int. We override the def
    // so that the generated schema is consistent with the real schema.
    @Column(nullable = false, columnDefinition = "integer")
    ReservationType reservationType;

    String comment;

    public String getComment(String comment) {
      return comment;
    }

    /** Creates a {@link ReservedListEntry} from a label, reservation type, and optional comment. */
    public static ReservedListEntry create(
        String label, ReservationType reservationType, @Nullable String comment) {
      return new ReservedListEntry.Builder()
          .setLabel(label)
          .setReservationType(reservationType)
          .setComment(comment)
          .build();
    }

    @Override
    public ReservationType getValue() {
      return reservationType;
    }

    @Override
    public ReservedListEntry.Builder asBuilder() {
      return new ReservedListEntry.Builder(clone(this));
    }

    @Override
    public String toString() {
      return String.format(
          "%s,%s%s", domainLabel, reservationType, isNullOrEmpty(comment) ? "" : " # " + comment);
    }

    /** A builder for constructing {@link ReservedListEntry} objects, since they are immutable. */
    public static class Builder
        extends DomainLabelEntry.Builder<ReservedListEntry, ReservedListEntry.Builder> {

      Builder() {}

      private Builder(ReservedListEntry instance) {
        super(instance);
      }

      ReservedListEntry.Builder setReservationType(ReservationType reservationType) {
        getInstance().reservationType = reservationType;
        return this;
      }

      ReservedListEntry.Builder setComment(String comment) {
        getInstance().comment = comment;
        return this;
      }
    }
  }

  @Override
  protected boolean refersToList(Tld tld, String name) {
    return tld.getReservedListNames().contains(name);
  }

  /** Determines whether the ReservedList is in use on any Registry */
  public boolean isInUse() {
    return !getReferencingTlds().isEmpty();
  }

  /**
   * Returns a {@link Map} of domain labels to {@link ReservedListEntry}.
   *
   * <p>Note that this involves a database fetch of a potentially large number of elements and
   * should be avoided unless necessary.
   *
   * <p>We use locking to memoize the resulting object. We cannot use a simple memoizing Supplier
   * because we need to be able to set this value when creating the lists.
   */
  public ImmutableMap<String, ReservedListEntry> getReservedListEntries() {
    if (reservedListMap == null) {
      synchronized (this) {
        // Extra null check to avoid race conditions
        if (reservedListMap == null) {
          reservedListMap =
              tm().reTransact(
                      () ->
                          tm()
                              .createQueryComposer(ReservedListEntry.class)
                              .where("revisionId", EQ, revisionId)
                              .stream()
                              .collect(toImmutableMap(ReservedListEntry::getDomainLabel, e -> e)));
        }
      }
    }
    return reservedListMap;
  }

  /**
   * Gets a ReservedList by name using the caching layer.
   *
   * @return An Optional&lt;ReservedList&gt; that has a value if a reserved list exists by the given
   *     name, or absent if not.
   * @throws UncheckedExecutionException if some other error occurs while trying to load the
   *     ReservedList from the cache or database.
   */
  public static Optional<ReservedList> get(String listName) {
    return cache.get(listName);
  }

  /**
   * Queries the set of all reserved lists associated with the specified TLD and returns the
   * reservation types of the label.
   *
   * <p>If the label is in none of the lists, it returns an empty set.
   */
  public static ImmutableSet<ReservationType> getReservationTypes(String label, String tld) {
    checkNotNull(label, "label");
    if (label.isEmpty()) {
      return ImmutableSet.of(FULLY_BLOCKED);
    }
    return getReservedListEntries(label, tld).stream()
        .map(ReservedListEntry::getValue)
        .collect(toImmutableSet());
  }

  /**
   * Helper function to retrieve the entries associated with this label and TLD, or an empty set if
   * no such entry exists.
   */
  private static ImmutableSet<ReservedListEntry> getReservedListEntries(
      String label, String tldStr) {
    Stopwatch stopwatch = Stopwatch.createStarted();
    Tld tld = Tld.get(checkNotNull(tldStr, "tld must not be null"));
    ImmutableSet.Builder<ReservedListEntry> entriesBuilder = new ImmutableSet.Builder<>();
    ImmutableSet.Builder<MetricsReservedListMatch> metricMatchesBuilder =
        new ImmutableSet.Builder<>();

    // Loop through all reservation lists and add each of them.
    for (ReservedList rl : loadReservedLists(tld.getReservedListNames())) {
      if (rl.getReservedListEntries().containsKey(label)) {
        ReservedListEntry entry = rl.getReservedListEntries().get(label);
        entriesBuilder.add(entry);
        metricMatchesBuilder.add(
            MetricsReservedListMatch.create(rl.getName(), entry.reservationType));
      }
    }
    ImmutableSet<ReservedListEntry> entries = entriesBuilder.build();
    DomainLabelMetrics.recordReservedListCheckOutcome(
        tldStr, metricMatchesBuilder.build(), stopwatch.elapsed().toMillis());
    return entries;
  }

  /** Loads and returns the reserved lists with the given names, skipping those that don't exist. */
  public static ImmutableSet<ReservedList> loadReservedLists(
      ImmutableSet<String> reservedListNames) {
    return cache.getAll(reservedListNames).values().stream()
        .flatMap(Optional::stream)
        .collect(toImmutableSet());
  }

  private static final LoadingCache<String, Optional<ReservedList>> cache =
      CacheUtils.newCacheBuilder(getDomainLabelListCacheDuration())
          .build(ReservedListDao::getLatestRevision);

  /**
   * Gets the {@link ReservationType} of a label in a single ReservedList, or returns an absent
   * Optional if none exists in the list.
   *
   * <p>Note that this logic is significantly less complicated than the {@link #getReservationTypes}
   * methods, which are applicable to an entire Registry, and need to check across multiple reserved
   * lists.
   */
  public Optional<ReservationType> getReservationInList(String label) {
    ReservedListEntry entry = getReservedListEntries().get(label);
    return Optional.ofNullable(entry == null ? null : entry.reservationType);
  }

  @Override
  @Nullable
  ReservedListEntry createFromLine(String originalLine) {
    List<String> lineAndComment = splitOnComment(originalLine);
    if (lineAndComment.isEmpty()) {
      return null;
    }
    String line = lineAndComment.get(0);
    String comment = lineAndComment.get(1);
    List<String> parts = Splitter.on(',').trimResults().splitToList(line);
    checkArgument(
        parts.size() == 2 || parts.size() == 3,
        "Could not parse line in reserved list: %s",
        originalLine);
    String label = parts.get(0);
    ReservationType reservationType = ReservationType.valueOf(parts.get(1));
    return ReservedListEntry.create(label, reservationType, comment);
  }

  @Override
  public Builder asBuilder() {
    return new Builder(clone(this));
  }

  /** A builder for constructing {@link ReservedList} objects, since they are immutable. */
  public static class Builder extends BaseDomainLabelList.Builder<ReservedList, Builder> {

    public Builder() {}

    private Builder(ReservedList instance) {
      super(instance);
    }

    public Builder setReservedListMap(ImmutableMap<String, ReservedListEntry> reservedListMap) {
      getInstance().reservedListMap = reservedListMap;
      return this;
    }

    /**
     * Updates the reservedListMap from input lines.
     *
     * @throws IllegalArgumentException if the lines cannot be parsed correctly.
     */
    public Builder setReservedListMapFromLines(Iterable<String> lines) {
      return setReservedListMap(getInstance().parse(lines));
    }
  }
}
