Skip to main content

alizarin_core/
instance_wrapper_core.rs

1/// Core ResourceInstanceWrapper types and business logic
2///
3/// This module contains platform-agnostic types and algorithms for:
4/// - Tile storage and indexing
5/// - Pseudo cache population (populate, ensure_nodegroup)
6/// - Semantic child value retrieval
7///
8/// The WASM and Python bindings wrap these types with platform-specific
9/// concerns (RefCell for WASM async, PyO3 for Python).
10use std::collections::{HashMap, HashSet};
11use std::sync::{Arc, Mutex};
12
13use crate::path_resolution::{resolve_path_segments, PathError, PathResolutionInfo};
14use crate::permissions::PermissionRule;
15use crate::pseudo_value_core::{PseudoListCore, PseudoValueCore};
16use crate::{StaticNode, StaticNodegroup, StaticResourceMetadata, StaticTile};
17
18// =============================================================================
19// Error types
20// =============================================================================
21
22/// Error type for semantic child value retrieval
23#[derive(Debug, Clone)]
24pub enum SemanticChildError {
25    /// Tiles for the required nodegroup have not been loaded yet
26    TilesNotLoaded { nodegroup_id: String },
27    /// Child node not found
28    ChildNotFound { alias: String },
29    /// Tiles storage not initialized
30    TilesNotInitialized,
31    /// Model not initialized
32    ModelNotInitialized(String),
33    /// Other error
34    Other(String),
35}
36
37impl std::fmt::Display for SemanticChildError {
38    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
39        match self {
40            SemanticChildError::TilesNotLoaded { nodegroup_id } => {
41                write!(f, "Tiles not loaded for nodegroup: {}", nodegroup_id)
42            }
43            SemanticChildError::ChildNotFound { alias } => {
44                write!(f, "Child node not found: {}", alias)
45            }
46            SemanticChildError::TilesNotInitialized => {
47                write!(f, "Tiles not initialized")
48            }
49            SemanticChildError::ModelNotInitialized(msg) => {
50                write!(f, "Model not initialized: {}", msg)
51            }
52            SemanticChildError::Other(msg) => {
53                write!(f, "{}", msg)
54            }
55        }
56    }
57}
58
59impl std::error::Error for SemanticChildError {}
60
61impl From<String> for SemanticChildError {
62    fn from(s: String) -> Self {
63        SemanticChildError::Other(s)
64    }
65}
66
67// =============================================================================
68// Load state tracking
69// =============================================================================
70
71/// Track loading state to prevent race conditions in lazy loading
72#[derive(Clone, Debug, PartialEq, Eq)]
73pub enum LoadState {
74    NotLoaded,
75    Loading,
76    Loaded,
77}
78
79// =============================================================================
80// Result types for nodegroup operations
81// =============================================================================
82
83/// Result from values_from_resource_nodegroup
84#[derive(Clone, Debug)]
85pub struct ValuesFromNodegroupResult {
86    /// Map of node alias → PseudoListCore
87    pub values: HashMap<String, PseudoListCore>,
88    /// Implied nodegroups that need loading
89    pub implied_nodegroups: Vec<String>,
90}
91
92/// Result from ensure_nodegroup
93#[derive(Clone, Debug)]
94pub struct EnsureNodegroupResult {
95    /// Structured values by alias
96    pub values: HashMap<String, PseudoListCore>,
97    /// Implied nodegroups
98    pub implied_nodegroups: Vec<String>,
99    /// All nodegroups map (nodegroup_id -> is_loaded)
100    pub all_nodegroups_map: HashMap<String, bool>,
101}
102
103/// Result from populate
104#[derive(Clone, Debug)]
105pub struct PopulateResult {
106    /// Map of alias → PseudoListCore
107    pub values: HashMap<String, PseudoListCore>,
108    /// All values map (alias -> is_truthy)
109    pub all_values_map: HashMap<String, Option<bool>>,
110    /// All nodegroups map (nodegroup_id -> is_loaded)
111    pub all_nodegroups_map: HashMap<String, bool>,
112}
113
114/// Result of get_semantic_child_value
115#[derive(Debug)]
116pub enum SemanticChildResult {
117    /// A list of pseudo values (for collectors or multiple matches)
118    List(PseudoListCore),
119    /// A single pseudo value
120    Single(PseudoValueCore),
121    /// No matching values found (not an error, just empty)
122    Empty,
123}
124
125// =============================================================================
126// Model access trait
127// =============================================================================
128
129/// Trait for accessing model data (nodes, edges, nodegroups)
130///
131/// This abstracts the model registry access pattern so that:
132/// - WASM can use thread-local MODEL_REGISTRY
133/// - Python can use a different storage mechanism
134/// - Tests can use mock implementations
135pub trait ModelAccess {
136    /// Get all nodes by ID
137    fn get_nodes(&self) -> Option<&HashMap<String, Arc<StaticNode>>>;
138
139    /// Get edges (parent_nodeid -> child_nodeids)
140    fn get_edges(&self) -> Option<&HashMap<String, Vec<String>>>;
141
142    /// Get reverse edges (child_nodeid -> parent_nodeids)
143    fn get_reverse_edges(&self) -> Option<&HashMap<String, Vec<String>>>;
144
145    /// Get nodes grouped by nodegroup
146    fn get_nodes_by_nodegroup(&self) -> Option<&HashMap<String, Vec<Arc<StaticNode>>>>;
147
148    /// Get all nodegroups by ID
149    fn get_nodegroups(&self) -> Option<&HashMap<String, Arc<StaticNodegroup>>>;
150
151    /// Get the root node of the graph.
152    /// Default implementation prefers `istopnode`, with fallback to
153    /// scanning for nodes with no `nodegroup_id`.
154    fn get_root_node(&self) -> Result<Arc<StaticNode>, String> {
155        let nodes = self
156            .get_nodes()
157            .ok_or_else(|| "Nodes not initialized".to_string())?;
158        // Prefer istopnode
159        for node in nodes.values() {
160            if node.istopnode {
161                return Ok(Arc::clone(node));
162            }
163        }
164        // Fallback: node with no nodegroup_id
165        for node in nodes.values() {
166            if node.nodegroup_id.is_none()
167                || node
168                    .nodegroup_id
169                    .as_ref()
170                    .map(|s| s.is_empty())
171                    .unwrap_or(true)
172            {
173                return Ok(Arc::clone(node));
174            }
175        }
176        Err("Could not find root node".to_string())
177    }
178
179    /// Get child nodes for a parent node, keyed by alias.
180    /// Default implementation derives from `get_edges()` and `get_nodes()`.
181    fn get_child_nodes(&self, node_id: &str) -> Result<HashMap<String, Arc<StaticNode>>, String> {
182        let edges = self.get_edges().ok_or("Edges not initialized")?;
183        let nodes = self.get_nodes().ok_or("Nodes not initialized")?;
184        let child_ids = edges.get(node_id).cloned().unwrap_or_default();
185        let mut children = HashMap::new();
186        for child_id in child_ids {
187            if let Some(node) = nodes.get(&child_id) {
188                if let Some(ref alias) = node.alias {
189                    if !alias.is_empty() {
190                        children.insert(alias.clone(), Arc::clone(node));
191                    }
192                }
193            }
194        }
195        Ok(children)
196    }
197
198    /// Get permitted nodegroups as permission rules.
199    /// Each rule may be a simple boolean or a conditional (per-tile) filter.
200    fn get_permitted_nodegroups(&self) -> HashMap<String, PermissionRule>;
201}
202
203// =============================================================================
204// Helper functions
205// =============================================================================
206
207/// Determine whether a node should be treated as single-cardinality.
208///
209/// A node is single-cardinality if:
210/// - It's the grouping node of its nodegroup AND the nodegroup's cardinality is not "n"
211/// - OR it's not a collector (default for leaf nodes)
212///
213/// Used by both the static path (populate → toJson) and dynamic path
214/// (getSemanticChildValue) to ensure consistent array/object serialization.
215///
216/// This version takes a closure for flexible nodegroup cardinality lookup,
217/// enabling use with different storage types (HashMap<String, Arc<StaticNodegroup>>,
218/// HashMap<String, StaticNodegroup>, GraphWrapper, etc.)
219///
220/// The closure should return `Some(cardinality_string)` if the nodegroup is found,
221/// or `None` if not found. Returns `Option<String>` to avoid lifetime complexity.
222pub fn is_node_single_cardinality_with<F>(node: &StaticNode, get_cardinality: F) -> bool
223where
224    F: Fn(&str) -> Option<String>,
225{
226    if let Some(ref ng_id) = node.nodegroup_id {
227        // Only check cardinality for the grouping node (nodeid == nodegroup_id)
228        if &node.nodeid == ng_id {
229            if let Some(cardinality) = get_cardinality(ng_id) {
230                return cardinality != "n";
231            }
232        }
233    }
234    // Default: collectors are multi, others single
235    !node.is_collector
236}
237
238/// Convenience wrapper for is_node_single_cardinality_with that takes a HashMap.
239///
240/// Used by instance_wrapper code that already has nodegroups indexed by ID.
241pub fn is_node_single_cardinality(
242    node: &StaticNode,
243    nodegroups: Option<&HashMap<String, Arc<StaticNodegroup>>>,
244) -> bool {
245    is_node_single_cardinality_with(node, |ng_id| {
246        nodegroups
247            .and_then(|ngs| ngs.get(ng_id))
248            .and_then(|ng| ng.cardinality.clone())
249    })
250}
251
252/// Check if a tile matches semantic parent-child relationship criteria
253///
254/// This implements the exact logic from SemanticViewModel.__getChildValues
255/// to determine if a value should be included as a child of a semantic node.
256pub fn matches_semantic_child(
257    parent_tile_id: Option<&String>,
258    parent_nodegroup_id: Option<&String>,
259    child_node: &StaticNode,
260    tile: &StaticTile,
261) -> bool {
262    // Check if tile's nodegroup matches the child node's nodegroup
263    if tile.nodegroup_id != *child_node.nodegroup_id.as_ref().unwrap_or(&"".into()) {
264        return false;
265    }
266
267    // We do not have a child value, unless there is a value, or the whole tile is the
268    // (semantic) value, or the child is a semantic node (which doesn't have direct tile data).
269    let is_semantic = child_node.datatype == "semantic";
270    if !(Some(&child_node.nodeid) == child_node.nodegroup_id.as_ref()
271        || tile.data.contains_key(&child_node.nodeid)
272        || is_semantic)
273    {
274        return false;
275    }
276
277    // Get the parent's nodegroup ID for comparisons
278    let parent_ng = parent_nodegroup_id.map(|s| s.as_str()).unwrap_or("");
279
280    // Branch 1: Different nodegroup + correct parent tile relationship
281    // This handles child nodes in a NESTED nodegroup (different from parent's nodegroup)
282    if tile.nodegroup_id != parent_ng {
283        if let Some(parent_tid) = parent_tile_id {
284            // Check if tile.parenttile_id is null or equals parent_tid
285            let parent_matches =
286                tile.parenttile_id.is_none() || tile.parenttile_id.as_ref() == Some(parent_tid);
287
288            if parent_matches {
289                return true;
290            }
291        } else {
292            // Parent has no tile (e.g. root node) — match if child tile also has no parent tile
293            if tile.parenttile_id.is_none() {
294                return true;
295            }
296        }
297    }
298
299    // Branch 2: Same nodegroup + shared tile + not collector
300    // This handles child nodes in the SAME nodegroup as parent (sharing a tile)
301    if tile.nodegroup_id == parent_ng {
302        if let Some(parent_tid) = parent_tile_id {
303            // Check if this tile IS the parent tile and child is not a collector
304            // For semantic nodes, we don't require tile data - they get their value
305            // from their children, not from tile data directly.
306            let has_data_or_is_semantic =
307                tile.data.contains_key(&child_node.nodeid) || child_node.datatype == "semantic";
308            if tile.tileid.as_ref() == Some(parent_tid)
309                && !child_node.is_collector
310                && has_data_or_is_semantic
311            {
312                return true;
313            }
314        }
315    }
316
317    // Branch 3: Different nodegroup + is_collector
318    // This handles collector nodes that don't share tiles with their parent
319    if tile.nodegroup_id != parent_ng && child_node.is_collector {
320        if let Some(parent_tid) = parent_tile_id {
321            return tile.parenttile_id.is_none() || tile.parenttile_id.as_ref() == Some(parent_tid);
322        }
323        return true;
324    }
325
326    false
327}
328
329/// Resolve a dot-separated path through the graph model and return matching tiles.
330///
331/// This is the shared implementation for path-based tile access. Takes tile storage
332/// and nodegroup index as parameters so it can be called by any wrapper (core, WASM,
333/// Python) with its own tile store.
334///
335/// If `filter_tile_id` is provided, only tiles matching the parent-child relationship
336/// are included (using `matches_semantic_child`).
337pub fn resolve_and_filter_tiles(
338    path: &str,
339    model: &dyn ModelAccess,
340    tiles_store: &HashMap<String, StaticTile>,
341    nodegroup_index: &HashMap<String, Vec<String>>,
342    filter_tile_id: Option<&str>,
343) -> Result<(PathResolutionInfo, Vec<StaticTile>), PathError> {
344    let root_node = model
345        .get_root_node()
346        .map_err(PathError::ModelNotInitialized)?;
347    let nodes = model
348        .get_nodes()
349        .ok_or_else(|| PathError::ModelNotInitialized("Nodes not initialized".into()))?;
350    let edges = model
351        .get_edges()
352        .ok_or_else(|| PathError::ModelNotInitialized("Edges not initialized".into()))?;
353    let nodegroups = model.get_nodegroups();
354
355    let info = resolve_path_segments(path, &root_node, nodes, edges, nodegroups)?;
356
357    let tile_ids = nodegroup_index
358        .get(&info.nodegroup_id)
359        .cloned()
360        .unwrap_or_default();
361
362    let tiles: Vec<StaticTile> = if let Some(filter_id) = filter_tile_id {
363        let filter_id_string = filter_id.to_string();
364        let parent_ng = info.parent_nodegroup_id.as_ref();
365        tile_ids
366            .iter()
367            .filter_map(|tid| {
368                tiles_store.get(tid).and_then(|t| {
369                    if matches_semantic_child(
370                        Some(&filter_id_string),
371                        parent_ng,
372                        &info.target_node,
373                        t,
374                    ) {
375                        Some(t.clone())
376                    } else {
377                        None
378                    }
379                })
380            })
381            .collect()
382    } else {
383        tile_ids
384            .iter()
385            .filter_map(|tid| tiles_store.get(tid).cloned())
386            .collect()
387    };
388
389    Ok((info, tiles))
390}
391
392// =============================================================================
393// Core resource instance wrapper
394// =============================================================================
395
396/// Type alias for alias -> (node, tiles) mapping used in values_from_resource_nodegroup
397type AliasTilesMap = HashMap<String, (Arc<StaticNode>, Vec<Option<Arc<StaticTile>>>)>;
398
399/// Create a PseudoListCore from a node and its tiles (standalone version).
400pub fn create_pseudo_list_from_tiles(
401    node: Arc<StaticNode>,
402    tiles: Vec<Option<Arc<StaticTile>>>,
403    edges: &HashMap<String, Vec<String>>,
404    is_single: bool,
405) -> PseudoListCore {
406    let alias = node.alias.clone().unwrap_or_default();
407    let child_node_ids = edges.get(&node.nodeid).cloned().unwrap_or_default();
408
409    let values: Vec<PseudoValueCore> = tiles
410        .into_iter()
411        .map(|tile| {
412            let tile_data = tile
413                .as_ref()
414                .and_then(|t| t.data.get(&node.nodeid).cloned());
415
416            PseudoValueCore::from_node_and_tile(
417                Arc::clone(&node),
418                tile,
419                tile_data,
420                child_node_ids.clone(),
421            )
422        })
423        .collect();
424
425    PseudoListCore::from_values_with_cardinality(alias, values, is_single)
426}
427
428/// Standalone values_from_resource_nodegroup — processes tiles and creates PseudoListCore
429/// entries for each node alias in the nodegroup.
430///
431/// Extracted from `ResourceInstanceWrapperCore::values_from_resource_nodegroup` so that
432/// WASM (and other bindings) can call this directly with their own tile store.
433pub fn values_from_resource_nodegroup(
434    existing_values: &HashMap<String, Option<bool>>,
435    nodegroup_tile_ids: &[String],
436    nodegroup_id: &str,
437    model: &dyn ModelAccess,
438    tiles_store: &HashMap<String, StaticTile>,
439) -> Result<ValuesFromNodegroupResult, SemanticChildError> {
440    let node_objs = model.get_nodes().ok_or_else(|| {
441        SemanticChildError::ModelNotInitialized("Model nodes not initialized".to_string())
442    })?;
443    let edges = model.get_edges().ok_or_else(|| {
444        SemanticChildError::ModelNotInitialized("Model edges not initialized".to_string())
445    })?;
446    let reverse_edges = model.get_reverse_edges().ok_or_else(|| {
447        SemanticChildError::ModelNotInitialized("Model reverse edges not initialized".to_string())
448    })?;
449    let nodes_by_nodegroup = model.get_nodes_by_nodegroup().ok_or_else(|| {
450        SemanticChildError::ModelNotInitialized(
451            "Model nodes-by-nodegroup not initialized".to_string(),
452        )
453    })?;
454    let nodegroups = model.get_nodegroups();
455
456    let mut values: HashMap<String, PseudoListCore> = HashMap::new();
457    let mut implied_nodegroups: HashSet<String> = HashSet::new();
458    let mut implied_nodes: HashMap<(String, String), (Arc<StaticNode>, Arc<StaticTile>)> =
459        HashMap::new();
460    let mut tile_nodes_seen: HashSet<(String, String)> = HashSet::new();
461
462    let mut alias_tiles: AliasTilesMap = HashMap::new();
463    let nodegroup_nodes = nodes_by_nodegroup.get(nodegroup_id);
464
465    for tile_id in nodegroup_tile_ids {
466        let tile = if tile_id.is_empty() {
467            None
468        } else {
469            tiles_store.get(tile_id).map(|t| Arc::new(t.clone()))
470        };
471        let tile_nodegroup_id = tile.as_ref().map(|t| t.nodegroup_id.clone());
472
473        if let Some(nodes_in_ng) = nodegroup_nodes {
474            for node in nodes_in_ng.iter() {
475                let alias = match &node.alias {
476                    Some(a) if !a.is_empty() => a.clone(),
477                    _ => continue,
478                };
479
480                if !tile_id.is_empty() {
481                    tile_nodes_seen.insert((node.nodeid.clone(), tile_id.clone()));
482                }
483
484                if let Some(Some(true)) = existing_values.get(&alias) {
485                    continue;
486                }
487
488                let entry = alias_tiles
489                    .entry(alias.clone())
490                    .or_insert_with(|| (Arc::clone(node), Vec::new()));
491                entry.1.push(tile.clone());
492
493                if let Some(parent_ids) = reverse_edges.get(&node.nodeid) {
494                    if let Some(parent_id) = parent_ids.first() {
495                        if let Some(domain_node) = node_objs.get(parent_id) {
496                            if let Some(ref domain_ng_id) = domain_node.nodegroup_id {
497                                if !domain_ng_id.is_empty() && domain_ng_id != nodegroup_id {
498                                    implied_nodegroups.insert(domain_ng_id.clone());
499                                }
500                                if let Some(ref tile_ng_id) = tile_nodegroup_id {
501                                    if domain_ng_id == tile_ng_id
502                                        && domain_ng_id != &domain_node.nodeid
503                                        && !tile_id.is_empty()
504                                    {
505                                        let key = (domain_node.nodeid.clone(), tile_id.clone());
506                                        if let Some(t) = tile.as_ref() {
507                                            implied_nodes.entry(key).or_insert_with(|| {
508                                                (Arc::clone(domain_node), Arc::clone(t))
509                                            });
510                                        }
511                                    }
512                                }
513                            }
514                        }
515                    }
516                }
517            }
518        }
519    }
520
521    // Process implied nodes
522    for (_key, (node, tile)) in implied_nodes.iter() {
523        if let Some(tid) = tile.tileid.as_ref() {
524            let key = (node.nodeid.clone(), tid.clone());
525            if !tile_nodes_seen.contains(&key) {
526                let alias = match &node.alias {
527                    Some(a) if !a.is_empty() => a.clone(),
528                    _ => continue,
529                };
530                tile_nodes_seen.insert(key);
531                if existing_values.get(&alias) != Some(&Some(true)) {
532                    let entry = alias_tiles
533                        .entry(alias.clone())
534                        .or_insert_with(|| (Arc::clone(node), Vec::new()));
535                    entry.1.push(Some(Arc::clone(tile)));
536                }
537            }
538        }
539    }
540
541    // Convert to PseudoListCore
542    for (alias, (node, tiles)) in alias_tiles {
543        let is_single = is_node_single_cardinality(&node, nodegroups);
544        let pseudo_list = create_pseudo_list_from_tiles(node, tiles, edges, is_single);
545        values.insert(alias, pseudo_list);
546    }
547
548    Ok(ValuesFromNodegroupResult {
549        values,
550        implied_nodegroups: implied_nodegroups.into_iter().collect(),
551    })
552}
553
554/// Standalone ensure_nodegroup — processes a single nodegroup and returns structured values.
555///
556/// Extracted from `ResourceInstanceWrapperCore::ensure_nodegroup` so that WASM
557/// (and other bindings) can call this directly with their own tile store.
558#[allow(clippy::too_many_arguments)]
559pub fn ensure_nodegroup(
560    all_values_map: &HashMap<String, Option<bool>>,
561    all_nodegroups: &mut HashMap<String, bool>,
562    nodegroup_id: &str,
563    add_if_missing: bool,
564    nodegroup_permissions: &HashMap<String, PermissionRule>,
565    do_implied_nodegroups: bool,
566    model: &dyn ModelAccess,
567    tiles_store: &HashMap<String, StaticTile>,
568) -> Result<EnsureNodegroupResult, SemanticChildError> {
569    let sentinel = all_nodegroups.get(nodegroup_id);
570    let should_process = match sentinel {
571        Some(&false) => true,
572        Some(&true) => false,
573        None => add_if_missing,
574    };
575
576    let mut all_values: HashMap<String, PseudoListCore> = HashMap::new();
577    let mut implied_nodegroups_set: HashSet<String> = HashSet::new();
578
579    if should_process {
580        let mut nodegroup_tiles: Vec<String> = Vec::new();
581
582        for (tile_id, tile) in tiles_store.iter() {
583            if tile.nodegroup_id == nodegroup_id {
584                let permitted = nodegroup_permissions
585                    .get(&tile.nodegroup_id)
586                    .map(|rule| rule.permits_tile(tile))
587                    .unwrap_or(true);
588                if permitted {
589                    nodegroup_tiles.push(tile_id.clone());
590                }
591            }
592        }
593
594        if nodegroup_tiles.is_empty() && add_if_missing {
595            nodegroup_tiles.push(String::new());
596        }
597
598        let values_result = values_from_resource_nodegroup(
599            all_values_map,
600            &nodegroup_tiles,
601            nodegroup_id,
602            model,
603            tiles_store,
604        )?;
605
606        for (alias, pseudo_list) in values_result.values {
607            all_values.insert(alias, pseudo_list);
608        }
609        for ng in values_result.implied_nodegroups.iter() {
610            implied_nodegroups_set.insert(ng.clone());
611        }
612
613        all_nodegroups.insert(nodegroup_id.to_string(), true);
614
615        if do_implied_nodegroups && !implied_nodegroups_set.is_empty() {
616            let implied_list: Vec<String> = implied_nodegroups_set.iter().cloned().collect();
617            for implied_ng in implied_list.iter() {
618                let implied_result = ensure_nodegroup(
619                    all_values_map,
620                    all_nodegroups,
621                    implied_ng,
622                    true,
623                    nodegroup_permissions,
624                    true,
625                    model,
626                    tiles_store,
627                )?;
628                for (alias, pseudo_list) in implied_result.values {
629                    all_values.insert(alias, pseudo_list);
630                }
631            }
632            implied_nodegroups_set.clear();
633        }
634    }
635
636    Ok(EnsureNodegroupResult {
637        values: all_values,
638        implied_nodegroups: implied_nodegroups_set.into_iter().collect(),
639        all_nodegroups_map: all_nodegroups.clone(),
640    })
641}
642
643/// Core resource instance wrapper - platform-agnostic business logic
644///
645/// Contains all tile storage, indexing, and business logic.
646/// Used by NAPI and Python bindings. WASM has a parallel copy in
647/// alizarin-wasm::instance_wrapper::ResourceInstanceWrapperCore that uses
648/// Rc<RefCell> instead of Arc<Mutex>.
649///
650/// TODO(priority): Unify the WASM copy with this struct so methods like
651/// set_tile_data_for_node don't need to be maintained in two places.
652pub struct ResourceInstanceWrapperCore {
653    /// Graph ID to look up model
654    pub graph_id: String,
655
656    /// Resource metadata
657    pub resource_instance: Option<StaticResourceMetadata>,
658
659    /// Tile storage (tileid -> tile)
660    pub tiles: Option<HashMap<String, StaticTile>>,
661
662    /// Index: nodegroup_id -> list of tile_ids
663    pub nodegroup_index: HashMap<String, Vec<String>>,
664
665    /// Track which nodegroups have been loaded/loading
666    pub loaded_nodegroups: Arc<Mutex<HashMap<String, LoadState>>>,
667
668    /// Cache of PseudoValues (alias -> PseudoListCore)
669    pub pseudo_cache: Arc<Mutex<HashMap<String, PseudoListCore>>>,
670
671    /// Cached model indices - avoids repeated lookups
672    pub cached_nodes: Option<Arc<HashMap<String, Arc<StaticNode>>>>,
673    pub cached_edges: Option<Arc<HashMap<String, Vec<String>>>>,
674    pub cached_reverse_edges: Option<Arc<HashMap<String, Vec<String>>>>,
675    pub cached_nodes_by_nodegroup: Option<Arc<HashMap<String, Vec<Arc<StaticNode>>>>>,
676    pub cached_nodegroups: Option<Arc<HashMap<String, Arc<StaticNodegroup>>>>,
677}
678
679impl ResourceInstanceWrapperCore {
680    /// Create a new empty core with just a graph ID
681    pub fn new(graph_id: String) -> Self {
682        ResourceInstanceWrapperCore {
683            graph_id,
684            resource_instance: None,
685            tiles: None,
686            nodegroup_index: HashMap::new(),
687            loaded_nodegroups: Arc::new(Mutex::new(HashMap::new())),
688            pseudo_cache: Arc::new(Mutex::new(HashMap::new())),
689            cached_nodes: None,
690            cached_edges: None,
691            cached_reverse_edges: None,
692            cached_nodes_by_nodegroup: None,
693            cached_nodegroups: None,
694        }
695    }
696
697    /// Set cached model indices from a ModelAccess implementation
698    pub fn set_cached_indices(&mut self, model: &dyn ModelAccess) {
699        self.cached_nodes = model.get_nodes().map(|m| Arc::new(m.clone()));
700        self.cached_edges = model.get_edges().map(|m| Arc::new(m.clone()));
701        self.cached_reverse_edges = model.get_reverse_edges().map(|m| Arc::new(m.clone()));
702        self.cached_nodes_by_nodegroup =
703            model.get_nodes_by_nodegroup().map(|m| Arc::new(m.clone()));
704        self.cached_nodegroups = model.get_nodegroups().map(|m| Arc::new(m.clone()));
705    }
706
707    /// Load tiles into the wrapper
708    pub fn load_tiles(&mut self, tiles: Vec<StaticTile>) {
709        let mut tiles_map = HashMap::new();
710        let mut nodegroup_index: HashMap<String, Vec<String>> = HashMap::new();
711
712        for tile in tiles {
713            let tile_id = tile
714                .tileid
715                .clone()
716                .unwrap_or_else(|| format!("synthetic_{}", tiles_map.len()));
717
718            // Index by nodegroup
719            nodegroup_index
720                .entry(tile.nodegroup_id.clone())
721                .or_default()
722                .push(tile_id.clone());
723
724            tiles_map.insert(tile_id, tile);
725        }
726
727        self.tiles = Some(tiles_map);
728        self.nodegroup_index = nodegroup_index;
729    }
730
731    /// Get a tile by ID
732    pub fn get_tile(&self, tile_id: &str) -> Option<&StaticTile> {
733        self.tiles.as_ref().and_then(|t| t.get(tile_id))
734    }
735
736    /// Set a single node's data in a tile, mutating in place.
737    /// Returns true if the tile was found and updated, false otherwise.
738    pub fn set_tile_data_for_node(
739        &mut self,
740        tile_id: &str,
741        node_id: &str,
742        value: serde_json::Value,
743    ) -> bool {
744        if let Some(tiles) = &mut self.tiles {
745            if let Some(tile) = tiles.get_mut(tile_id) {
746                tile.data.insert(node_id.to_string(), value);
747                return true;
748            }
749        }
750        false
751    }
752
753    /// Get tiles for a nodegroup
754    pub fn get_tiles_for_nodegroup(&self, nodegroup_id: &str) -> Vec<&StaticTile> {
755        let tile_ids = self.nodegroup_index.get(nodegroup_id);
756        match (tile_ids, &self.tiles) {
757            (Some(ids), Some(tiles)) => ids.iter().filter_map(|id| tiles.get(id)).collect(),
758            _ => Vec::new(),
759        }
760    }
761
762    /// Check if a nodegroup is loaded
763    pub fn is_nodegroup_loaded(&self, nodegroup_id: &str) -> bool {
764        if let Ok(loaded) = self.loaded_nodegroups.lock() {
765            matches!(loaded.get(nodegroup_id), Some(LoadState::Loaded))
766        } else {
767            false
768        }
769    }
770
771    /// Mark a nodegroup as loaded
772    pub fn mark_nodegroup_loaded(&self, nodegroup_id: &str) {
773        if let Ok(mut loaded) = self.loaded_nodegroups.lock() {
774            loaded.insert(nodegroup_id.to_string(), LoadState::Loaded);
775        }
776    }
777
778    /// Get a cached pseudo list by alias
779    pub fn get_cached_pseudo(&self, alias: &str) -> Option<PseudoListCore> {
780        if let Ok(cache) = self.pseudo_cache.lock() {
781            cache.get(alias).cloned()
782        } else {
783            None
784        }
785    }
786
787    /// Store a pseudo list in the cache
788    pub fn store_pseudo(&self, alias: String, pseudo_list: PseudoListCore) {
789        if let Ok(mut cache) = self.pseudo_cache.lock() {
790            cache.insert(alias, pseudo_list);
791        }
792    }
793
794    /// Build pseudo values from tiles for a nodegroup.
795    ///
796    /// Delegates to the standalone `values_from_resource_nodegroup` function,
797    /// passing this wrapper's tile store.
798    pub fn values_from_resource_nodegroup(
799        &self,
800        existing_values: &HashMap<String, Option<bool>>,
801        nodegroup_tile_ids: &[String],
802        nodegroup_id: &str,
803        model: &dyn ModelAccess,
804    ) -> Result<ValuesFromNodegroupResult, SemanticChildError> {
805        let tiles_store = match &self.tiles {
806            Some(t) => t,
807            None => {
808                return Ok(ValuesFromNodegroupResult {
809                    values: HashMap::new(),
810                    implied_nodegroups: Vec::new(),
811                });
812            }
813        };
814        values_from_resource_nodegroup(
815            existing_values,
816            nodegroup_tile_ids,
817            nodegroup_id,
818            model,
819            tiles_store,
820        )
821    }
822
823    /// Process a single nodegroup and return structured values.
824    ///
825    /// Delegates to the standalone `ensure_nodegroup` function,
826    /// passing this wrapper's tile store.
827    #[allow(clippy::too_many_arguments)]
828    pub fn ensure_nodegroup(
829        &self,
830        all_values_map: &HashMap<String, Option<bool>>,
831        all_nodegroups: &mut HashMap<String, bool>,
832        nodegroup_id: &str,
833        add_if_missing: bool,
834        nodegroup_permissions: &HashMap<String, PermissionRule>,
835        do_implied_nodegroups: bool,
836        model: &dyn ModelAccess,
837    ) -> Result<EnsureNodegroupResult, SemanticChildError> {
838        let tiles_store = match &self.tiles {
839            Some(t) => t,
840            None => {
841                return Ok(EnsureNodegroupResult {
842                    values: HashMap::new(),
843                    implied_nodegroups: Vec::new(),
844                    all_nodegroups_map: all_nodegroups.clone(),
845                });
846            }
847        };
848        ensure_nodegroup(
849            all_values_map,
850            all_nodegroups,
851            nodegroup_id,
852            add_if_missing,
853            nodegroup_permissions,
854            do_implied_nodegroups,
855            model,
856            tiles_store,
857        )
858    }
859
860    /// Main populate implementation
861    ///
862    /// Orchestrates loading all nodegroups for a resource.
863    pub fn populate(
864        &self,
865        lazy: bool,
866        nodegroup_ids: &[String],
867        root_node_alias: &str,
868        model: &dyn ModelAccess,
869    ) -> Result<PopulateResult, SemanticChildError> {
870        let nodegroup_permissions = model.get_permitted_nodegroups();
871
872        // Check if pseudo_cache has been populated
873        let cache_len = if let Ok(cache) = self.pseudo_cache.lock() {
874            cache.len()
875        } else {
876            0
877        };
878        let cache_populated = cache_len > 1;
879
880        // Get already-loaded nodegroups
881        let already_loaded: HashSet<String> = if cache_populated {
882            if let Ok(loaded) = self.loaded_nodegroups.lock() {
883                loaded
884                    .iter()
885                    .filter(|(_, state)| **state == LoadState::Loaded)
886                    .map(|(id, _)| id.clone())
887                    .collect()
888            } else {
889                HashSet::new()
890            }
891        } else {
892            HashSet::new()
893        };
894
895        // Filter nodegroup_ids to only those not already loaded
896        let nodegroups_to_process: Vec<String> = nodegroup_ids
897            .iter()
898            .filter(|id| !already_loaded.contains(*id))
899            .cloned()
900            .collect();
901
902        // Initialize state maps
903        let mut all_values: HashMap<String, Option<bool>> = HashMap::new();
904        let mut all_nodegroups: HashMap<String, bool> = HashMap::new();
905
906        // Initialize nodegroups
907        for nodegroup_id in nodegroup_ids.iter() {
908            let is_loaded = already_loaded.contains(nodegroup_id);
909            all_nodegroups.insert(nodegroup_id.clone(), is_loaded);
910        }
911
912        // Set root node alias to false
913        all_values.insert(root_node_alias.to_string(), Some(false));
914
915        // Start with existing cache entries
916        let mut all_structured_values: HashMap<String, PseudoListCore> =
917            if !already_loaded.is_empty() {
918                if let Ok(cache) = self.pseudo_cache.lock() {
919                    cache.clone()
920                } else {
921                    HashMap::new()
922                }
923            } else {
924                HashMap::new()
925            };
926
927        // Non-lazy loading: process all nodegroups
928        if !lazy {
929            let mut implied_nodegroups_set = HashSet::new();
930
931            // Phase 1: Process all nodegroups with doImpliedNodegroups=false
932            for nodegroup_id in nodegroups_to_process.iter() {
933                let result = self.ensure_nodegroup(
934                    &all_values,
935                    &mut all_nodegroups,
936                    nodegroup_id,
937                    true,
938                    &nodegroup_permissions,
939                    false,
940                    model,
941                )?;
942
943                for (alias, pseudo_list) in result.values {
944                    all_structured_values.insert(alias, pseudo_list);
945                }
946
947                for implied_ng in result.implied_nodegroups.iter() {
948                    if implied_ng != nodegroup_id {
949                        implied_nodegroups_set.insert(implied_ng.clone());
950                    }
951                }
952            }
953
954            // Phase 2: Process implied nodegroups iteratively
955            while !implied_nodegroups_set.is_empty() {
956                let current_implied: Vec<String> = implied_nodegroups_set.iter().cloned().collect();
957                implied_nodegroups_set.clear();
958
959                for nodegroup_id in current_implied.iter() {
960                    let current_value = all_nodegroups.get(nodegroup_id);
961                    let should_process = matches!(current_value, Some(&false) | None);
962
963                    if should_process {
964                        let result = self.ensure_nodegroup(
965                            &all_values,
966                            &mut all_nodegroups,
967                            nodegroup_id,
968                            true,
969                            &nodegroup_permissions,
970                            true,
971                            model,
972                        )?;
973
974                        for (alias, pseudo_list) in result.values {
975                            all_structured_values.insert(alias, pseudo_list);
976                        }
977
978                        for implied_ng in result.implied_nodegroups.iter() {
979                            implied_nodegroups_set.insert(implied_ng.clone());
980                        }
981                    }
982                }
983            }
984        }
985
986        // Create root pseudo value
987        let root_node = model
988            .get_root_node()
989            .map_err(SemanticChildError::ModelNotInitialized)?;
990        let edges = model.get_edges().ok_or_else(|| {
991            SemanticChildError::ModelNotInitialized("Model edges not initialized".to_string())
992        })?;
993
994        let child_node_ids = edges.get(&root_node.nodeid).cloned().unwrap_or_default();
995
996        let root_pseudo =
997            PseudoValueCore::from_node_and_tile(root_node, None, None, child_node_ids);
998
999        let root_list = PseudoListCore::from_values_with_cardinality(
1000            root_node_alias.to_string(),
1001            vec![root_pseudo],
1002            true,
1003        );
1004
1005        all_structured_values.insert(root_node_alias.to_string(), root_list);
1006
1007        // Store all values in pseudo_cache
1008        if let Ok(mut cache) = self.pseudo_cache.lock() {
1009            for (alias, pseudo_list) in all_structured_values.iter() {
1010                cache.insert(alias.clone(), pseudo_list.clone());
1011            }
1012        }
1013
1014        Ok(PopulateResult {
1015            values: all_structured_values,
1016            all_values_map: all_values,
1017            all_nodegroups_map: all_nodegroups,
1018        })
1019    }
1020
1021    /// Get semantic child values for a given parent node and child alias
1022    pub fn get_semantic_child_value(
1023        &self,
1024        parent_tile_id: Option<&String>,
1025        parent_node_id: &str,
1026        parent_nodegroup_id: Option<&String>,
1027        child_alias: &str,
1028        model: &dyn ModelAccess,
1029    ) -> Result<SemanticChildResult, SemanticChildError> {
1030        // Get child nodes for this parent
1031        let child_nodes = model
1032            .get_child_nodes(parent_node_id)
1033            .map_err(SemanticChildError::ModelNotInitialized)?;
1034
1035        // Find the child node by alias
1036        let child_node = child_nodes
1037            .values()
1038            .find(|n| n.alias.as_deref() == Some(child_alias))
1039            .ok_or_else(|| SemanticChildError::ChildNotFound {
1040                alias: child_alias.to_string(),
1041            })?;
1042
1043        // Get tiles storage
1044        let tiles = self
1045            .tiles
1046            .as_ref()
1047            .ok_or(SemanticChildError::TilesNotInitialized)?;
1048
1049        // Filter tiles that match the semantic child relationship
1050        let matching_tiles: Vec<Arc<StaticTile>> = tiles
1051            .values()
1052            .filter(|tile| {
1053                matches_semantic_child(parent_tile_id, parent_nodegroup_id, child_node, tile)
1054            })
1055            .map(|t| Arc::new(t.clone()))
1056            .collect();
1057
1058        if matching_tiles.is_empty() {
1059            return Ok(SemanticChildResult::Empty);
1060        }
1061
1062        // Get edges for child_node_ids
1063        let edges = model.get_edges().ok_or_else(|| {
1064            SemanticChildError::ModelNotInitialized("Model edges not initialized".to_string())
1065        })?;
1066        let child_node_ids = edges.get(&child_node.nodeid).cloned().unwrap_or_default();
1067
1068        // Determine cardinality
1069        let nodegroups = model.get_nodegroups();
1070        let is_single = is_node_single_cardinality(child_node, nodegroups);
1071
1072        // Create pseudo values
1073        let values: Vec<PseudoValueCore> = matching_tiles
1074            .into_iter()
1075            .map(|tile| {
1076                let tile_data = tile.data.get(&child_node.nodeid).cloned();
1077                PseudoValueCore::from_node_and_tile(
1078                    Arc::clone(child_node),
1079                    Some(tile),
1080                    tile_data,
1081                    child_node_ids.clone(),
1082                )
1083            })
1084            .collect();
1085
1086        if is_single && values.len() == 1 {
1087            Ok(SemanticChildResult::Single(
1088                values.into_iter().next().unwrap(),
1089            ))
1090        } else {
1091            let list = PseudoListCore::from_values_with_cardinality(
1092                child_alias.to_string(),
1093                values,
1094                is_single,
1095            );
1096            Ok(SemanticChildResult::List(list))
1097        }
1098    }
1099
1100    /// Resolve a dot-separated path through the graph model.
1101    ///
1102    /// Walks the graph edges matching node aliases at each segment,
1103    /// returning the target node's metadata and nodegroup ID.
1104    pub fn resolve_path(
1105        &self,
1106        path: &str,
1107        model: &dyn ModelAccess,
1108    ) -> Result<PathResolutionInfo, PathError> {
1109        let root_node = model
1110            .get_root_node()
1111            .map_err(PathError::ModelNotInitialized)?;
1112        let nodes = model
1113            .get_nodes()
1114            .ok_or_else(|| PathError::ModelNotInitialized("Nodes not initialized".into()))?;
1115        let edges = model
1116            .get_edges()
1117            .ok_or_else(|| PathError::ModelNotInitialized("Edges not initialized".into()))?;
1118        let nodegroups = model.get_nodegroups();
1119
1120        resolve_path_segments(path, &root_node, nodes, edges, nodegroups)
1121    }
1122
1123    /// Resolve a path and return the resolution info plus matching tiles.
1124    ///
1125    /// Delegates to the standalone `resolve_and_filter_tiles` function,
1126    /// passing this wrapper's tile store and nodegroup index.
1127    pub fn resolve_and_filter_tiles(
1128        &self,
1129        path: &str,
1130        model: &dyn ModelAccess,
1131        filter_tile_id: Option<&str>,
1132    ) -> Result<(PathResolutionInfo, Vec<StaticTile>), PathError> {
1133        let tiles_store = self.tiles.as_ref().ok_or(PathError::TilesNotInitialized)?;
1134        resolve_and_filter_tiles(
1135            path,
1136            model,
1137            tiles_store,
1138            &self.nodegroup_index,
1139            filter_tile_id,
1140        )
1141    }
1142
1143    /// Resolve a dot-separated path and return a PseudoListCore for the target node.
1144    ///
1145    /// This is the main entry point for path-based value access. It walks the graph
1146    /// model to find the target node, then retrieves matching tiles from the store
1147    /// and builds a PseudoListCore — all without materializing the full tree.
1148    ///
1149    /// If `filter_tile_id` is provided, only tiles that match the parent-child
1150    /// relationship for that tile are included (using `matches_semantic_child`).
1151    pub fn get_values_at_path(
1152        &self,
1153        path: &str,
1154        model: &dyn ModelAccess,
1155        filter_tile_id: Option<&str>,
1156    ) -> Result<PseudoListCore, PathError> {
1157        let (info, tiles) = self.resolve_and_filter_tiles(path, model, filter_tile_id)?;
1158
1159        let edges = model
1160            .get_edges()
1161            .ok_or_else(|| PathError::ModelNotInitialized("Edges not initialized".into()))?;
1162
1163        let tiles_wrapped: Vec<Option<Arc<StaticTile>>> =
1164            tiles.into_iter().map(|t| Some(Arc::new(t))).collect();
1165
1166        let pseudo_list =
1167            create_pseudo_list_from_tiles(info.target_node, tiles_wrapped, edges, info.is_single);
1168
1169        Ok(pseudo_list)
1170    }
1171}
1172
1173#[cfg(test)]
1174mod tests {
1175    use super::*;
1176
1177    #[test]
1178    fn test_load_state_equality() {
1179        assert_eq!(LoadState::NotLoaded, LoadState::NotLoaded);
1180        assert_eq!(LoadState::Loading, LoadState::Loading);
1181        assert_eq!(LoadState::Loaded, LoadState::Loaded);
1182        assert_ne!(LoadState::NotLoaded, LoadState::Loaded);
1183    }
1184
1185    #[test]
1186    fn test_is_node_single_cardinality_collector() {
1187        let node = Arc::new(StaticNode {
1188            nodeid: "test-node".to_string(),
1189            name: "Test Node".to_string(),
1190            alias: Some("test".to_string()),
1191            datatype: "string".to_string(),
1192            is_collector: true,
1193            nodegroup_id: Some("test-nodegroup".to_string()),
1194            graph_id: "test-graph".to_string(),
1195            isrequired: false,
1196            exportable: true,
1197            sortorder: None,
1198            config: HashMap::new(),
1199            parentproperty: None,
1200            ontologyclass: None,
1201            description: None,
1202            fieldname: None,
1203            hascustomalias: false,
1204            issearchable: false,
1205            istopnode: false,
1206            sourcebranchpublication_id: None,
1207            source_identifier_id: None,
1208            is_immutable: None,
1209        });
1210
1211        // Collector nodes are multi-cardinality by default
1212        assert!(!is_node_single_cardinality(&node, None));
1213    }
1214
1215    #[test]
1216    fn test_is_node_single_cardinality_non_collector() {
1217        let node = Arc::new(StaticNode {
1218            nodeid: "test-node".to_string(),
1219            name: "Test Node".to_string(),
1220            alias: Some("test".to_string()),
1221            datatype: "string".to_string(),
1222            is_collector: false,
1223            nodegroup_id: Some("test-nodegroup".to_string()),
1224            graph_id: "test-graph".to_string(),
1225            isrequired: false,
1226            exportable: true,
1227            sortorder: None,
1228            config: HashMap::new(),
1229            parentproperty: None,
1230            ontologyclass: None,
1231            description: None,
1232            fieldname: None,
1233            hascustomalias: false,
1234            issearchable: false,
1235            istopnode: false,
1236            sourcebranchpublication_id: None,
1237            source_identifier_id: None,
1238            is_immutable: None,
1239        });
1240
1241        // Non-collector nodes are single-cardinality by default
1242        assert!(is_node_single_cardinality(&node, None));
1243    }
1244}