geolite 0.0.18

open source geocode solution
use std::collections::HashMap;

use geo::{Centroid, Closest, ClosestPoint, Geometry, Point};
use rusqlite::Connection;

use super::entity::{leaf, match_sources, query_match, round5};
use super::house_number;
use super::{filter, query_opts};
use crate::admin_level::level;
use crate::admin_level::repository::admin_area_row;
use crate::admin_level_hierarchy::search_index::{
  self, search_hit, search_tier, tantivy_index, tokenize,
};
use crate::house_number::{house_number_resolution, house_number_scenario, policy, token};

const MAX_FTS_HITS: u8 = 50;

// a region restricts the ranking inside tantivy rather than filtering after the fts cut, so a
// match of the region ranked below the global cap is not lost; the exact containment of the
// polygon runs in the filters
pub(super) fn region_of(conn: &Connection, opts: &query_opts) -> Option<Vec<i64>> {
  opts
    .bounding
    .as_ref()
    .map(|b| crate::admin_level::spatial_index::ids_in_bounding_box(conn, b.envelope))
}

// the fts runs on the full text: a number can be part of the street name ("25" in
// "rua 25 de marco"), so stripping it would break the match; the house number is resolved per
// candidate afterwards, under the policy of the candidate's country, which is not known here:
// the words are read under every shape, and they only mark optional tokens
pub(super) fn hits_at(
  index: &tantivy_index,
  tier: search_tier,
  text: &str,
  opts: &query_opts,
  region: Option<&[i64]>,
) -> Vec<search_hit> {
  if region.is_some_and(|ids| ids.is_empty()) {
    return vec![];
  }
  index.search(
    tier,
    text,
    &token::house_number_words(text, &policy::EVERY_SHAPE),
    MAX_FTS_HITS as usize,
    opts.last_admin_levels.as_deref(),
    region,
  )
}

pub(super) fn matches_of(
  conn: &Connection,
  iso: &str,
  text: &str,
  hits: &[search_hit],
  opts: &query_opts,
) -> Vec<query_match> {
  let query_tokens = tokenize(text);
  let mut ids: Vec<i64> = hits.iter().map(|hit| hit.admin_level_id).collect();
  ids.sort_unstable();
  ids.dedup();
  let mut sources = match_sources::load(conn, iso, &ids);
  sources.load_leaf_boxes(conn);
  let records = crate::admin_level::repository::load_full_by_ids(conn, &ids);
  let record_map: HashMap<i64, &admin_area_row> = records.iter().map(|r| (r.id, r)).collect();
  let streets: Vec<&admin_area_row> = records
    .iter()
    .filter(|r| r.admin_level == level::street)
    .collect();
  let numbers =
    house_number::from_query(conn, text, &streets, |id| sources.house_number_policy_of(id));

  // one match per hit: a hit is one path of an area, and its ordinal names the path
  let mut matches: Vec<query_match> = hits
    .iter()
    .filter_map(|hit| {
      let record = record_map.get(&hit.admin_level_id)?;
      let Some(path) = sources.path_at(hit.admin_level_id, hit.ordinal) else {
        crate::debug!(
          "debug: hit {} ordinal {} has no path: the index is out of step with the hierarchy",
          hit.admin_level_id,
          hit.ordinal
        );
        return None;
      };
      build_match(record, &sources, &query_tokens, hit, path, numbers.get(&record.id), opts)
    })
    .collect();

  // bm25 first; among the segments of one street, which share a score, the one that placed the
  // number wins the tie through the similarity nudge, and the rest keep the collector's order,
  // which is by area id
  matches.sort_by(|a, b| {
    b.score
      .partial_cmp(&a.score)
      .unwrap_or(std::cmp::Ordering::Equal)
      .then_with(|| {
        b.similarity
          .partial_cmp(&a.similarity)
          .unwrap_or(std::cmp::Ordering::Equal)
      })
  });

  filter::apply_filters_and_truncate(&mut matches, opts);
  matches
}

pub(super) fn resting_point(
  record: &admin_area_row,
  geometry: &Geometry<f64>,
  sources: &match_sources,
  path: &[i64],
) -> Option<Point<f64>> {
  let centroid = geometry.centroid()?;
  if record.admin_level != level::street {
    return Some(centroid);
  }
  let toward = match (sources.paths_of(record.id).len() > 1, path.first()) {
    (true, Some(&leaf)) => sources.center_of(leaf).unwrap_or(centroid),
    _ => centroid,
  };
  match geometry.closest_point(&toward) {
    Closest::SinglePoint(point) | Closest::Intersection(point) => Some(point),
    Closest::Indeterminate => Some(centroid),
  }
}

fn build_match(
  record: &admin_area_row,
  sources: &match_sources,
  query_tokens: &[String],
  hit: &search_hit,
  path: &[i64],
  resolution: Option<&house_number_resolution>,
  opts: &query_opts,
) -> Option<query_match> {
  let geom = record.wkb.as_ref()?.geometry();
  let centroid = resting_point(record, geom, sources, path)?;
  let point = resolution.map_or(centroid, |placed| placed.point);

  let ancestors = sources.ancestors_of(path.iter());
  let leaf = leaf {
    id: record.id,
    level: record.admin_level,
    name: &record.name,
    relation_id: record.relation_id,
    way_id: record.way_id,
  };
  let own_meta = sources.meta.get(&record.id);
  let coverage = search_index::coverage(
    query_tokens,
    (&record.name, own_meta.and_then(|m| m.post_code.as_deref())),
    ancestors.iter().map(|a| (a.name.as_str(), a.post_code.as_deref())),
  );

  let mut similarity = round5(coverage as f64) as f32;
  // nudge similarity so a match whose number came from the street's own numbers outranks the
  // bare street and the ones that presumed it from less
  if resolution.is_some_and(|placed| {
    matches!(
      placed.origin.scenario(),
      house_number_scenario::from_osm_data
        | house_number_scenario::presumed_from_multiple_references_from_street
    )
  }) {
    similarity = round5(similarity as f64 + 0.01) as f32;
  }
  let reported = resolution.map(house_number::reported);

  Some(query_match {
    similarity: Some(similarity),
    score: Some(hit.score),
    ..sources.match_at(
      &leaf,
      &ancestors,
      path,
      point,
      reported.as_ref(),
      opts.friendly_name_format,
    )
  })
}