// lib/editor/search/search_ranking.dart
//
// Pure ranking + aggregation for full-text search (F8). Given many sources (PDF
// text pages, typed boxes, OCR'd handwriting) keyed by a ref string, score each
// for a query and return the best hits with display snippets. This is the
// search_indexer's ranking core; the FTS pre-filter/index lives in the DB.
//
// Pure (no storage/widgets); builds on search_text (normalize/match) +
// search_snippet (excerpt). Fully unit-tested.

import 'search_snippet.dart';
import 'search_text.dart';

/// A ranked search result: WHERE it is ([ref]), the display [snippet], and the
/// [score] (higher = better).
class SearchHit {
  const SearchHit({required this.ref, required this.snippet, required this.score});

  final String ref;
  final Snippet snippet;
  final double score;

  @override
  String toString() => 'SearchHit($ref, score=${score.toStringAsFixed(3)})';
}

/// Score [source] for [query]: more (normalized) occurrences rank higher, and an
/// earlier first match breaks ties. 0 when there is no match.
double scoreText(String source, String query) {
  final q = normalizeForIndex(query);
  if (q.isEmpty) return 0;
  final s = normalizeForIndex(source);
  if (s.isEmpty) return 0;

  var count = 0;
  var from = 0;
  var firstPos = -1;
  while (true) {
    final idx = s.indexOf(q, from);
    if (idx < 0) break;
    if (firstPos < 0) firstPos = idx;
    count++;
    from = idx + q.length;
  }
  if (count == 0) return 0;

  // Earliness in (0,1]: a match at position 0 scores 1.0.
  final earliness = 1.0 - (firstPos / s.length);
  return count + earliness;
}

/// Build + rank hits across [sources] (ref → raw text) for [query]: drop
/// non-matches, attach a display snippet of the ORIGINAL text, sort best-first.
/// Ties (equal score) keep input order (stable).
List<SearchHit> rankHits(
  Map<String, String> sources,
  String query, {
  int window = 80,
}) {
  final indexed = <({int order, SearchHit hit})>[];
  var order = 0;
  for (final entry in sources.entries) {
    final i = order++;
    final score = scoreText(entry.value, query);
    if (score <= 0) continue;
    final snippet = snippetFor(entry.value, query, window: window);
    if (snippet == null) continue; // matched normalized but not raw (rare)
    indexed.add((
      order: i,
      hit: SearchHit(ref: entry.key, snippet: snippet, score: score),
    ));
  }
  // Descending score; ties keep input order (Dart's sort isn't stable, so the
  // input index is an explicit tiebreaker).
  indexed.sort((a, b) {
    final byScore = b.hit.score.compareTo(a.hit.score);
    return byScore != 0 ? byScore : a.order.compareTo(b.order);
  });
  return [for (final e in indexed) e.hit];
}
