///|
/// Faceted search: structured keyword fields for filtering and aggregation.
/// Facets are exact-match (not analyzed): each document carries zero or more
/// string values per facet field. Queries can filter by facet values, and
/// search results can include per-facet value counts (aggregations).

///|
/// One facet value with its document count in a result set.
pub(all) struct FacetCount {
  value : String
  count : Int32
} derive(Eq, Debug)

///|
pub extend FacetCount with Eq::{equal, not_equal}

///|
pub extend FacetCount with @debug.Debug::{to_repr}

///|
/// A facet filter: field name + required values (OR within, AND across fields).
pub(all) struct FacetFilter {
  field : String
  values : Array[String]
} derive(Eq, Debug)

///|
pub extend FacetFilter with Eq::{equal, not_equal}

///|
pub extend FacetFilter with @debug.Debug::{to_repr}

///|
/// Search options extending the basic query with facets and pagination.
pub(all) struct SearchOptions {
  limit : Int32
  /// Numeric offset into the sorted result set: skip the first `offset`
  /// hits. NOTE: unlike `Index::search_after`, this is NOT a (score, id)
  /// cursor — pages can shift when documents are added or removed between
  /// calls. Use `search_after` for stable cursor-based pagination.
  offset : Int32
  /// Facet filters: a document must match at least one value per filter.
  filters : Array[FacetFilter]
  /// Facet fields to aggregate counts for in the result set.
  facet_fields : Array[String]
} derive(Eq, Debug)

///|
pub extend SearchOptions with Eq::{equal, not_equal}

///|
pub extend SearchOptions with @debug.Debug::{to_repr}

///|
pub fn SearchOptions::default() -> SearchOptions {
  { limit: 10, offset: 0, filters: [], facet_fields: [], }
}

///|
/// Search result with optional facet aggregations.
pub(all) struct FacetedResult {
  hits : Array[Hit]
  total : Int32
  /// field name -> value counts, sorted by descending count then value.
  facets : Array[(String, Array[FacetCount])]
} derive(Eq, Debug)

///|
pub extend FacetedResult with Eq::{equal, not_equal}

///|
pub extend FacetedResult with @debug.Debug::{to_repr}

///|
/// Set facet values for a document (replaces any previous values).
/// Returns false (and does nothing) when the document does not exist or
/// is deleted; true when the values were stored. Unknown fields are
/// created on the fly (facets don't need schema declaration).
pub fn Index::set_facets(
  self : Index,
  id : String,
  facets : Array[(String, Array[String])],
) -> Bool {
  match self.doc_nums.get(id) {
    Some(num) if !self.deleted.contains(num) => {
      self.facets.set(num, facets)
      true
    }
    _ => false
  }
}

///|
/// Get facet values for a document.
pub fn Index::get_facets(
  self : Index,
  id : String,
) -> Array[(String, Array[String])] {
  match self.doc_nums.get(id) {
    Some(num) if !self.deleted.contains(num) =>
      self.facets.get_or_default(num, [])
    _ => []
  }
}

///|
/// Check if a document passes all facet filters.
fn Index::passes_filters(
  self : Index,
  doc : Int32,
  filters : Array[FacetFilter],
) -> Bool {
  if filters.is_empty() {
    return true
  }
  let doc_facets = self.facets.get_or_default(doc, [])
  for filter in filters {
    let mut matched = false
    for (name, values) in doc_facets {
      if name == filter.field {
        for v in filter.values {
          if values.contains(v) {
            matched = true
            break
          }
        }
      }
      if matched {
        break
      }
    }
    if !matched {
      return false
    }
  }
  true
}

///|
/// Faceted search: query + filters + aggregations + pagination.
pub fn Index::search_faceted(
  self : Index,
  query : String,
  options~ : SearchOptions = SearchOptions::default(),
) -> FacetedResult raise QueryError {
  let parsed = parse_query(
    query,
    analyzer=self.analyzer,
    fields=self.field_names(),
  )
  let scored = self.eval(parsed)
  // Apply facet filters and collect all matching hits.
  let all_hits : Array[Hit] = []
  let matching_docs : Array[Int32] = []
  for (doc, score) in scored.iter() {
    if self.passes_filters(doc, options.filters) {
      all_hits.push({ id: self.doc_ids[doc], score, })
      matching_docs.push(doc)
    }
  }
  all_hits.sort_by(fn(a, b) {
    let cmp = b.score.compare(a.score)
    if cmp == 0 {
      a.id.lexical_compare(b.id)
    } else {
      cmp
    }
  })
  let total = all_hits.length()
  // Pagination
  let start = if options.offset < 0 { 0 } else { options.offset }
  let end = if options.limit <= 0 {
    total
  } else {
    let e = start + options.limit
    if e > total {
      total
    } else {
      e
    }
  }
  let hits : Array[Hit] = []
  if start < total {
    for i in start.. Json {
  let fields : Map[String, Json] = Map([])
  fields["id"] = @json.to_json(self.id)
  fields["score"] = @json.to_json(self.score)
  Json::object(fields)
}

///|
pub fn ScoreDetail::to_json(self : ScoreDetail) -> Json {
  let fields : Map[String, Json] = Map([])
  fields["field"] = @json.to_json(self.field)
  fields["term"] = @json.to_json(self.term)
  fields["tf"] = @json.to_json(self.tf)
  fields["df"] = @json.to_json(self.df)
  fields["idf"] = @json.to_json(self.idf)
  fields["norm"] = @json.to_json(self.norm)
  fields["score"] = @json.to_json(self.score)
  Json::object(fields)
}

///|
pub fn Explanation::to_json(self : Explanation) -> Json {
  let fields : Map[String, Json] = Map([])
  fields["id"] = @json.to_json(self.id)
  fields["total_score"] = @json.to_json(self.total_score)
  fields["details"] = Json::array(self.details.map(fn(d) { d.to_json() }))
  Json::object(fields)
}

///|
pub fn FacetCount::to_json(self : FacetCount) -> Json {
  Json::array([@json.to_json(self.value), @json.to_json(self.count)])
}

///|
pub fn SearchOptions::to_json(self : SearchOptions) -> Json {
  let fields : Map[String, Json] = Map([])
  fields["limit"] = @json.to_json(self.limit)
  fields["offset"] = @json.to_json(self.offset)
  Json::object(fields)
}

///|
pub fn FacetFilter::to_json(self : FacetFilter) -> Json {
  let fields : Map[String, Json] = Map([])
  fields["field"] = @json.to_json(self.field)
  fields["values"] = Json::array(self.values.map(fn(v) { @json.to_json(v) }))
  Json::object(fields)
}

///|
pub fn FacetedResult::to_json(self : FacetedResult) -> Json {
  let fields : Map[String, Json] = Map([])
  fields["hits"] = Json::array(self.hits.map(fn(h) { h.to_json() }))
  fields["total"] = @json.to_json(self.total)
  let facet_obj : Map[String, Json] = Map([])
  for (name, counts) in self.facets {
    facet_obj[name] = Json::array(counts.map(fn(c) { c.to_json() }))
  }
  fields["facets"] = Json::object(facet_obj)
  Json::object(fields)
}

///|
pub impl Default for SearchOptions with fn default() -> SearchOptions {
  { limit: 10, offset: 0, filters: [], facet_fields: [], }
}