ifc-cost 0.2.3

Cost semantics as a borrowed view over the IFC model.
Documentation
use std::collections::{HashMap, HashSet};

use ifc_model::{guid::Guid, Edit, EntityId, Model, Transaction, Value};

use super::{CostAuthoringError, CostAuthoringResult};

pub(crate) fn guid(
    tx: &Transaction,
    model: &Model,
    entity: &'static str,
    value: &str,
) -> CostAuthoringResult<()> {
    if Guid::parse(value).is_none() {
        return Err(invalid(
            entity,
            "GlobalId",
            "expected a compressed IFC GUID",
        ));
    }
    let mut projected: HashMap<EntityId, String> = model
        .iter()
        .filter_map(|(id, record)| record.text(0).map(|global_id| (id, global_id.to_owned())))
        .collect();
    for edit in tx.edits() {
        match edit {
            Edit::Create { id, entity } => {
                if let Some(global_id) = entity.text(0) {
                    projected.insert(*id, global_id.to_owned());
                } else {
                    projected.remove(id);
                }
            }
            Edit::SetAttribute { id, slot: 0, value } if projected.contains_key(id) => {
                if let Some(global_id) = value.unwrap_typed().as_text() {
                    projected.insert(*id, global_id.to_owned());
                } else {
                    projected.remove(id);
                }
            }
            Edit::Remove { id } => {
                projected.remove(id);
            }
            Edit::SetAttribute { .. } | Edit::Retype { .. } => {}
        }
    }
    if projected.values().any(|global_id| global_id == value) {
        return Err(invalid(entity, "GlobalId", "duplicate GlobalId"));
    }
    Ok(())
}

pub(crate) fn reference_type(
    tx: &Transaction,
    model: &Model,
    owner: &'static str,
    attribute: &'static str,
    target: EntityId,
    expected: &'static str,
) -> CostAuthoringResult<()> {
    let Some(actual) = projected_type(tx, model, target) else {
        return Err(CostAuthoringError::MissingReference {
            entity: owner,
            attribute,
            target,
        });
    };
    if !actual.eq_ignore_ascii_case(expected) {
        return Err(CostAuthoringError::WrongReferenceType {
            entity: owner,
            attribute,
            target,
            actual,
            expected,
        });
    }
    Ok(())
}

pub(crate) fn non_empty_unique(
    entity: &'static str,
    attribute: &'static str,
    ids: &[EntityId],
) -> CostAuthoringResult<()> {
    if ids.is_empty() {
        return Err(invalid(entity, attribute, "expected at least one member"));
    }
    let mut seen = HashSet::new();
    if ids.iter().any(|id| !seen.insert(*id)) {
        return Err(invalid(
            entity,
            attribute,
            "duplicate members are not allowed",
        ));
    }
    Ok(())
}

pub(crate) fn validate_nesting(
    tx: &Transaction,
    model: &Model,
    parent: EntityId,
    children: &[EntityId],
) -> CostAuthoringResult<()> {
    let edges = nesting_edges(tx, model);
    let mut parents = HashMap::new();
    let mut next: HashMap<EntityId, Vec<EntityId>> = HashMap::new();
    for (from, to) in edges {
        parents.entry(to).or_insert(from);
        next.entry(from).or_default().push(to);
    }
    for child in children {
        if *child == parent {
            return Err(CostAuthoringError::NestingCycle { item: *child });
        }
        if let Some(existing_parent) = parents.get(child) {
            return Err(CostAuthoringError::MultipleParents {
                child: *child,
                existing_parent: *existing_parent,
            });
        }
        if reachable(&next, *child, parent) {
            return Err(CostAuthoringError::NestingCycle { item: *child });
        }
    }
    Ok(())
}

pub(crate) fn projected_type(tx: &Transaction, model: &Model, target: EntityId) -> Option<String> {
    for edit in tx.edits().iter().rev() {
        match edit {
            Edit::Remove { id } if *id == target => return None,
            Edit::Retype { id, type_name } if *id == target => return Some(type_name.to_string()),
            Edit::Create { id, entity } if *id == target => {
                return Some(entity.type_name.to_string())
            }
            _ => {}
        }
    }
    model.get(target).map(|entity| entity.type_name.to_string())
}

fn nesting_edges(tx: &Transaction, model: &Model) -> Vec<(EntityId, EntityId)> {
    let mut projected: HashMap<EntityId, _> = model
        .iter()
        .map(|(id, entity)| (id, entity.clone()))
        .collect();
    for edit in tx.edits() {
        match edit {
            Edit::Create { id, entity } => {
                projected.insert(*id, entity.clone());
            }
            Edit::SetAttribute { id, slot, value } => {
                if let Some(entity) = projected.get_mut(id) {
                    if *slot >= entity.attributes.len() {
                        entity.attributes.resize(*slot + 1, Value::Null);
                    }
                    entity.attributes[*slot] = value.clone();
                }
            }
            Edit::Retype { id, type_name } => {
                if let Some(entity) = projected.get_mut(id) {
                    entity.type_name = type_name.clone();
                }
            }
            Edit::Remove { id } => {
                projected.remove(id);
            }
        }
    }
    let mut out = Vec::new();
    for entity in projected
        .values()
        .filter(|entity| entity.is_type("IFCRELNESTS"))
    {
        append_edges(entity.attribute(4), entity.attribute(5), &mut out);
    }
    out
}

fn append_edges(
    parent: Option<&Value>,
    children: Option<&Value>,
    out: &mut Vec<(EntityId, EntityId)>,
) {
    let Some(Value::Ref(parent)) = parent else {
        return;
    };
    if let Some(children) = children {
        children.for_each_ref(&mut |child| out.push((*parent, child)));
    }
}

fn reachable(next: &HashMap<EntityId, Vec<EntityId>>, start: EntityId, goal: EntityId) -> bool {
    let mut stack = vec![start];
    let mut seen = HashSet::new();
    while let Some(node) = stack.pop() {
        if node == goal {
            return true;
        }
        if seen.insert(node) {
            stack.extend(next.get(&node).into_iter().flatten().copied());
        }
    }
    false
}

pub(crate) fn invalid(
    entity: &'static str,
    attribute: &'static str,
    reason: impl Into<String>,
) -> CostAuthoringError {
    CostAuthoringError::InvalidValue {
        entity,
        attribute,
        reason: reason.into(),
    }
}