/*
 * Licensed to the Apache Software Foundation (ASF) under one or more
 * contributor license agreements.  See the NOTICE file distributed with
 * this work for additional information regarding copyright ownership.
 * The ASF licenses this file to You 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 org.apache.lucene.monitor;

import java.io.IOException;
import java.util.Collection;
import java.util.Collections;
import java.util.Locale;
import java.util.Map;
import java.util.Set;
import java.util.TreeMap;
import java.util.TreeSet;
import org.apache.lucene.index.LeafReaderContext;
import org.apache.lucene.search.Matches;
import org.apache.lucene.search.MatchesIterator;
import org.apache.lucene.search.Query;
import org.apache.lucene.search.ScoreMode;
import org.apache.lucene.search.Weight;

/**
 * QueryMatch object that contains the hit positions of a matching Query
 *
 * <p>If the Query does not support interval iteration (eg, if it gets re-written to a Filter), then
 * no hits will be reported, but an IntervalsQueryMatch will still be returned from an
 * IntervalsMatcher to indicate a match.
 */
public class HighlightsMatch extends QueryMatch {

  public static final MatcherFactory<HighlightsMatch> MATCHER =
      searcher ->
          new CandidateMatcher<HighlightsMatch>(searcher) {

            @Override
            protected void matchQuery(
                String queryId, Query matchQuery, Map<String, String> metadata) throws IOException {
              Weight w =
                  searcher.createWeight(
                      searcher.rewrite(matchQuery), ScoreMode.COMPLETE_NO_SCORES, 1);
              for (LeafReaderContext ctx : searcher.getIndexReader().leaves()) {
                for (int i = 0; i < ctx.reader().maxDoc(); i++) {
                  Matches matches = w.matches(ctx, i);
                  if (matches != null) {
                    addMatch(buildMatch(matches, queryId), i);
                  }
                }
              }
            }

            @Override
            public HighlightsMatch resolve(HighlightsMatch match1, HighlightsMatch match2) {
              return HighlightsMatch.merge(match1.getQueryId(), match1, match2);
            }

            private HighlightsMatch buildMatch(Matches matches, String queryId) throws IOException {
              HighlightsMatch m = new HighlightsMatch(queryId);
              for (String field : matches) {
                MatchesIterator mi = matches.getMatches(field);
                while (mi.next()) {
                  MatchesIterator sub = mi.getSubMatches();
                  if (sub != null) {
                    while (sub.next()) {
                      m.addHit(
                          field,
                          sub.startPosition(),
                          sub.endPosition(),
                          sub.startOffset(),
                          sub.endOffset());
                    }
                  } else {
                    m.addHit(
                        field,
                        mi.startPosition(),
                        mi.endPosition(),
                        mi.startOffset(),
                        mi.endOffset());
                  }
                }
              }
              return m;
            }
          };

  private final Map<String, Set<Hit>> hits;

  HighlightsMatch(String queryId) {
    super(queryId);
    this.hits = new TreeMap<>();
  }

  /**
   * @return a map of hits per field
   */
  public Map<String, Set<Hit>> getHits() {
    return Collections.unmodifiableMap(this.hits);
  }

  /**
   * @return the fields in which matches have been found
   */
  public Set<String> getFields() {
    return Collections.unmodifiableSet(hits.keySet());
  }

  /**
   * Get the hits for a specific field
   *
   * @param field the field
   * @return the Hits found in this field
   */
  public Collection<Hit> getHits(String field) {
    Collection<Hit> found = hits.get(field);
    if (found != null) return Collections.unmodifiableCollection(found);
    return Collections.emptyList();
  }

  /**
   * @return the total number of hits for the query
   */
  public int getHitCount() {
    int c = 0;
    for (Set<Hit> fieldhits : hits.values()) {
      c += fieldhits.size();
    }
    return c;
  }

  static HighlightsMatch merge(String queryId, HighlightsMatch... matches) {
    HighlightsMatch newMatch = new HighlightsMatch(queryId);
    for (HighlightsMatch match : matches) {
      for (String field : match.getFields()) {
        Set<Hit> hitSet = newMatch.hits.computeIfAbsent(field, f -> new TreeSet<>());
        hitSet.addAll(match.getHits(field));
      }
    }
    return newMatch;
  }

  @Override
  public boolean equals(Object o) {
    if (this == o) return true;
    if (!(o instanceof HighlightsMatch)) return false;
    if (!super.equals(o)) return false;

    HighlightsMatch that = (HighlightsMatch) o;

    if (hits != null ? !hits.equals(that.hits) : that.hits != null) return false;

    return true;
  }

  @Override
  public int hashCode() {
    int result = super.hashCode();
    result = 31 * result + (hits != null ? hits.hashCode() : 0);
    return result;
  }

  @Override
  public String toString() {
    return super.toString() + "{hits=" + hits + "}";
  }

  void addHit(String field, int startPos, int endPos, int startOffset, int endOffset) {
    Set<Hit> hitSet = hits.computeIfAbsent(field, f -> new TreeSet<>());
    hitSet.add(new Hit(startPos, startOffset, endPos, endOffset));
  }

  /** Represents an individual hit */
  public static class Hit implements Comparable<Hit> {

    /** The start position */
    public final int startPosition;

    /** The start offset */
    public final int startOffset;

    /** The end positions */
    public final int endPosition;

    /** The end offset */
    public final int endOffset;

    public Hit(int startPosition, int startOffset, int endPosition, int endOffset) {
      this.startPosition = startPosition;
      this.startOffset = startOffset;
      this.endPosition = endPosition;
      this.endOffset = endOffset;
    }

    @Override
    public boolean equals(Object obj) {
      if (this == obj) return true;
      if (!(obj instanceof Hit)) return false;
      Hit other = (Hit) obj;
      return this.startOffset == other.startOffset
          && this.endOffset == other.endOffset
          && this.startPosition == other.startPosition
          && this.endPosition == other.endPosition;
    }

    @Override
    public int hashCode() {
      int result = startPosition;
      result = 31 * result + startOffset;
      result = 31 * result + endPosition;
      result = 31 * result + endOffset;
      return result;
    }

    @Override
    public String toString() {
      return String.format(
          Locale.ROOT, "%d(%d)->%d(%d)", startPosition, startOffset, endPosition, endOffset);
    }

    @Override
    public int compareTo(Hit other) {
      if (this.startPosition != other.startPosition)
        return Integer.compare(this.startPosition, other.startPosition);
      return Integer.compare(this.endPosition, other.endPosition);
    }
  }
}
