///|
fn catalog_type_counts(index : ModelIndex) -> Map[String, Int] {
  let counts : Map[String, Int] = Map([])
  for entity in index.all() {
    let name = entity.name.to_upper()
    counts.update_or_default(name, 1, count => count + 1)
  }
  counts
}

///|
/// Build the indexes that are normally needed by an application session.
///
/// The catalog deliberately keeps the original `Model` alongside its indexes.
/// This makes it possible to move from an optimized query back to a lossless
/// entity without reparsing the source text.
pub fn ModelCatalog::new(model : Model) -> ModelCatalog raise ParseError {
  let index = model.index()
  let products = model.products()
  let property_sets = model.property_sets()
  let roots = spatial_roots(index)
  let metrics = analyze_model(model.to_step(), model)
  {
    model,
    index,
    products,
    property_sets,
    roots,
    metrics,
    type_counts: catalog_type_counts(index),
  }
}

///|
/// Construct a reusable catalog from an IFC STEP source.
pub fn catalog(source : String) -> ModelCatalog raise ParseError {
  ModelCatalog::new(parse(source))
}

///|
pub fn ModelCatalog::entity(self : ModelCatalog, id : Int) -> Entity? {
  self.index.entity(id)
}

///|
pub fn ModelCatalog::has_entity(self : ModelCatalog, id : Int) -> Bool {
  self.index.has(id)
}

///|
pub fn ModelCatalog::type_count(self : ModelCatalog, name : String) -> Int {
  self.type_counts.get(name.to_upper()).unwrap_or(0)
}

///|
pub fn ModelCatalog::schema(self : ModelCatalog) -> String {
  self.model.schema()
}

///|
pub fn ModelCatalog::summary(self : ModelCatalog) -> ModelSummary {
  self.model.summary()
}

///|
pub fn ModelCatalog::products_of_kind(
  self : ModelCatalog,
  kind : ProductKind,
) -> Array[Product] {
  self.products.filter(product => product.kind == kind)
}

///|
pub fn ModelCatalog::products_named(
  self : ModelCatalog,
  fragment : String,
) -> Array[Product] {
  let folded = fragment.to_lower()
  self.products.filter(product => {
    match product.name {
      Some(name) => name.to_lower().contains(folded)
      None => false
    }
  })
}

///|
pub fn ModelCatalog::property_sets_for(
  self : ModelCatalog,
  product : Product,
) -> Array[PropertySet] raise ParseError {
  property_sets_for_product(self.index, product)
}

///|
pub fn ModelCatalog::property(
  self : ModelCatalog,
  product : Product,
  name : String,
) -> Property? raise ParseError {
  property_named(self.property_sets_for(product), name)
}

///|
pub fn ModelCatalog::spatial_root(self : ModelCatalog) -> SpatialNode? {
  self.roots.get(0)
}

///|
pub fn ModelCatalog::type_histogram(self : ModelCatalog) -> Map[String, Int] {
  self.type_counts
}

///|
pub fn CatalogQuery::new() -> CatalogQuery {
  {
    kind: None,
    ifc_type: None,
    name_contains: None,
    property_contains: None,
    container: None,
    sort_key: EntityId,
    direction: Ascending,
    offset: 0,
    limit: 100,
  }
}

///|
pub fn CatalogQuery::of_kind(
  self : CatalogQuery,
  kind : ProductKind,
) -> CatalogQuery {
  { ..self, kind: Some(kind) }
}

///|
pub fn CatalogQuery::of_type(
  self : CatalogQuery,
  ifc_type : String,
) -> CatalogQuery {
  { ..self, ifc_type: Some(ifc_type.to_upper()) }
}

///|
pub fn CatalogQuery::named(
  self : CatalogQuery,
  fragment : String,
) -> CatalogQuery {
  { ..self, name_contains: Some(fragment.to_lower()) }
}

///|
pub fn CatalogQuery::with_property(
  self : CatalogQuery,
  fragment : String,
) -> CatalogQuery {
  { ..self, property_contains: Some(fragment.to_lower()) }
}

///|
pub fn CatalogQuery::in_container(
  self : CatalogQuery,
  container : Int,
) -> CatalogQuery {
  { ..self, container: Some(container) }
}

///|
pub fn CatalogQuery::sorted_by(
  self : CatalogQuery,
  sort_key : CatalogSortKey,
  direction : SortDirection,
) -> CatalogQuery {
  { ..self, sort_key, direction }
}

///|
pub fn CatalogQuery::page(
  self : CatalogQuery,
  offset : Int,
  limit : Int,
) -> CatalogQuery {
  {
    ..self,
    offset: if offset < 0 {
      0
    } else {
      offset
    },
    limit: if limit < 0 {
      0
    } else {
      limit
    },
  }
}

///|
fn query_name(product : Product) -> String {
  product.name.unwrap_or("").to_lower()
}

///|
fn query_kind_name(product : Product) -> String {
  match product.kind {
    Project => "project"
    Site => "site"
    Building => "building"
    Storey => "storey"
    Space => "space"
    Wall => "wall"
    Door => "door"
    Window => "window"
    Beam => "beam"
    Column => "column"
    Slab => "slab"
    Roof => "roof"
    Stair => "stair"
    Ramp => "ramp"
    Opening => "opening"
    Generic => "generic"
  }
}

///|
fn product_property_matches(
  index : ModelIndex,
  product : Product,
  fragment : String,
) -> Bool raise ParseError {
  let needle = fragment.to_lower()
  for property_set in property_sets_for_product(index, product) {
    if property_set.name.unwrap_or("").to_lower().contains(needle) {
      return true
    }
    for property in property_set.properties {
      if property.name.to_lower().contains(needle) ||
        property.value.to_step().to_lower().contains(needle) {
        return true
      }
    }
  }
  false
}

///|
fn product_matches_query(
  index : ModelIndex,
  product : Product,
  query : CatalogQuery,
) -> Bool raise ParseError {
  let kind_ok = match query.kind {
    Some(kind) => product.kind == kind
    None => true
  }
  let type_ok = match query.ifc_type {
    Some(name) => product.ifc_type.to_upper() == name
    None => true
  }
  let name_ok = match query.name_contains {
    Some(fragment) => query_name(product).contains(fragment)
    None => true
  }
  let property_ok = match query.property_contains {
    Some(fragment) => product_property_matches(index, product, fragment)
    None => true
  }
  let container_ok = match query.container {
    Some(id) => product.container == Some(id)
    None => true
  }
  kind_ok && type_ok && name_ok && property_ok && container_ok
}

///|
fn product_sort_value(product : Product, key : CatalogSortKey) -> String {
  match key {
    EntityId => product.id.to_string()
    EntityType => product.ifc_type.to_upper()
    ProductName => query_name(product)
    ProductKind => query_kind_name(product)
    PropertyValue => product.tag.unwrap_or("").to_lower()
  }
}

///|
fn product_precedes(
  left : Product,
  right : Product,
  key : CatalogSortKey,
  direction : SortDirection,
) -> Bool {
  let comparison = String::compare(
    product_sort_value(left, key),
    product_sort_value(right, key),
  )
  match direction {
    Ascending => comparison < 0
    Descending => comparison > 0
  }
}

///|
fn stable_product_sort(
  products : Array[Product],
  key : CatalogSortKey,
  direction : SortDirection,
) -> Array[Product] {
  let result = products.copy()
  let length = result.length()
  for i in 0.. CatalogPage raise ParseError {
  let matched = self.products.filter(product => {
    product_matches_query(self.index, product, query)
  })
  let sorted = stable_product_sort(matched, query.sort_key, query.direction)
  let total = sorted.length()
  let offset = if query.offset > total { total } else { query.offset }
  let requested_end = offset + query.limit
  let end = if requested_end > total { total } else { requested_end }
  let page = if query.limit == 0 || offset == end {
    []
  } else {
    sorted[offset:end].to_owned()
  }
  {
    products: page,
    total_matches: total,
    offset,
    limit: query.limit,
    has_next: end < total,
  }
}

///|
pub fn ModelCatalog::query_all(
  self : ModelCatalog,
  query : CatalogQuery,
) -> Array[Product] raise ParseError {
  let page = self.query({ ..query, offset: 0, limit: self.products.length() })
  page.products
}

///|
pub fn catalog_query(
  model : Model,
  query : CatalogQuery,
) -> Array[Product] raise ParseError {
  ModelCatalog::new(model).query_all(query)
}