Skip to main content

alizarin_core/graph/
resources.rs

1//! Resource types for resource instances and metadata.
2
3use super::descriptors::StaticResourceDescriptors;
4use super::tile::StaticTile;
5use serde::{Deserialize, Serialize};
6use std::collections::{HashMap, HashSet};
7
8/// Metadata about a resource instance
9#[derive(Clone, Debug, Serialize, Deserialize)]
10pub struct StaticResourceMetadata {
11    pub descriptors: StaticResourceDescriptors,
12    pub graph_id: String,
13    pub name: String,
14    pub resourceinstanceid: String,
15    #[serde(skip_serializing_if = "Option::is_none")]
16    pub publication_id: Option<String>,
17    #[serde(skip_serializing_if = "Option::is_none")]
18    pub principaluser_id: Option<i32>,
19    #[serde(default)]
20    pub legacyid: Option<String>,
21    #[serde(skip_serializing_if = "Option::is_none")]
22    pub graph_publication_id: Option<String>,
23    #[serde(skip_serializing_if = "Option::is_none")]
24    pub createdtime: Option<String>,
25    #[serde(skip_serializing_if = "Option::is_none")]
26    pub lastmodified: Option<String>,
27}
28
29/// Summary info for a resource (used for lazy loading)
30#[derive(Clone, Debug, Serialize, Deserialize)]
31pub struct StaticResourceSummary {
32    pub resourceinstanceid: String,
33    pub graph_id: String,
34    pub name: String,
35    #[serde(default, skip_serializing_if = "Option::is_none")]
36    pub descriptors: Option<StaticResourceDescriptors>,
37    #[serde(default)]
38    pub metadata: HashMap<String, String>,
39    #[serde(skip_serializing_if = "Option::is_none")]
40    pub createdtime: Option<String>,
41    #[serde(skip_serializing_if = "Option::is_none")]
42    pub lastmodified: Option<String>,
43    #[serde(skip_serializing_if = "Option::is_none")]
44    pub publication_id: Option<String>,
45    #[serde(skip_serializing_if = "Option::is_none")]
46    pub principaluser_id: Option<i32>,
47    #[serde(default)]
48    pub legacyid: Option<String>,
49    #[serde(skip_serializing_if = "Option::is_none")]
50    pub graph_publication_id: Option<String>,
51}
52
53impl StaticResourceSummary {
54    /// Convert summary to metadata
55    pub fn to_metadata(&self) -> StaticResourceMetadata {
56        StaticResourceMetadata {
57            descriptors: self.descriptors.clone().unwrap_or_default(),
58            graph_id: self.graph_id.clone(),
59            name: self.name.clone(),
60            resourceinstanceid: self.resourceinstanceid.clone(),
61            publication_id: self.publication_id.clone(),
62            principaluser_id: self.principaluser_id,
63            legacyid: self.legacyid.clone(),
64            graph_publication_id: self.graph_publication_id.clone(),
65            createdtime: self.createdtime.clone(),
66            lastmodified: self.lastmodified.clone(),
67        }
68    }
69}
70
71/// Reference to another resource instance (for resource-instance datatype)
72///
73/// Used in ResourceInstanceViewModel to represent relationships between resources.
74/// Can include the full resource tree if cascade is enabled.
75#[derive(Clone, Debug, Serialize, Deserialize)]
76pub struct StaticResourceReference {
77    /// Resource instance ID
78    pub id: String,
79    /// Graph ID for the resource model
80    #[serde(rename = "graphId")]
81    pub graph_id: String,
82    /// Resource model type/name (optional)
83    #[serde(skip_serializing_if = "Option::is_none", rename = "type")]
84    pub resource_type: Option<String>,
85    /// Display title for the resource (optional)
86    #[serde(skip_serializing_if = "Option::is_none")]
87    pub title: Option<String>,
88    /// Full resource tree data (when cascaded, optional)
89    #[serde(skip_serializing_if = "Option::is_none")]
90    pub root: Option<serde_json::Value>,
91    /// Additional metadata (optional)
92    #[serde(skip_serializing_if = "Option::is_none")]
93    pub meta: Option<HashMap<String, serde_json::Value>>,
94}
95
96impl StaticResourceReference {
97    /// Create a minimal resource reference with just ID and graph ID
98    pub fn new(id: String, graph_id: String) -> Self {
99        StaticResourceReference {
100            id,
101            graph_id,
102            resource_type: None,
103            title: None,
104            root: None,
105            meta: None,
106        }
107    }
108
109    /// Create a reference with type information
110    pub fn with_type(id: String, graph_id: String, resource_type: String) -> Self {
111        StaticResourceReference {
112            id,
113            graph_id,
114            resource_type: Some(resource_type),
115            title: None,
116            root: None,
117            meta: None,
118        }
119    }
120
121    /// Add title to reference (builder pattern)
122    pub fn with_title(mut self, title: String) -> Self {
123        self.title = Some(title);
124        self
125    }
126
127    /// Add metadata to reference (builder pattern)
128    pub fn with_meta(mut self, meta: HashMap<String, serde_json::Value>) -> Self {
129        self.meta = Some(meta);
130        self
131    }
132
133    /// Add root data to reference for cascaded loading (builder pattern)
134    pub fn with_root(mut self, root: serde_json::Value) -> Self {
135        self.root = Some(root);
136        self
137    }
138}
139
140/// Complete resource data with tiles
141#[derive(Clone, Debug, Serialize, Deserialize)]
142pub struct StaticResource {
143    pub resourceinstance: StaticResourceMetadata,
144    #[serde(skip_serializing_if = "Option::is_none")]
145    pub tiles: Option<Vec<StaticTile>>,
146    #[serde(default)]
147    pub metadata: HashMap<String, String>,
148
149    // Optional cache and scopes - stored as JSON for platform independence
150    #[serde(skip_serializing_if = "Option::is_none", default, rename = "__cache")]
151    pub cache: Option<serde_json::Value>,
152    #[serde(skip_serializing_if = "Option::is_none", default, rename = "__scopes")]
153    pub scopes: Option<serde_json::Value>,
154
155    // Tracking flag for lazy loading
156    #[serde(skip_serializing_if = "Option::is_none", default)]
157    pub tiles_loaded: Option<bool>,
158}
159
160impl StaticResource {
161    /// Convert to a summary (for registry storage)
162    pub fn to_summary(&self) -> StaticResourceSummary {
163        StaticResourceSummary {
164            resourceinstanceid: self.resourceinstance.resourceinstanceid.clone(),
165            graph_id: self.resourceinstance.graph_id.clone(),
166            name: self.resourceinstance.name.clone(),
167            descriptors: Some(self.resourceinstance.descriptors.clone()),
168            metadata: self.metadata.clone(),
169            createdtime: self.resourceinstance.createdtime.clone(),
170            lastmodified: self.resourceinstance.lastmodified.clone(),
171            publication_id: self.resourceinstance.publication_id.clone(),
172            principaluser_id: self.resourceinstance.principaluser_id,
173            legacyid: self.resourceinstance.legacyid.clone(),
174            graph_publication_id: self.resourceinstance.graph_publication_id.clone(),
175        }
176    }
177}
178
179/// Cache entry for a related resource, matching ResourceInstanceCacheEntry from TypeScript.
180///
181/// This structure is compatible with TypeScript's getValueCache format,
182/// allowing direct lookup by tileId/nodeId when rendering ViewModels.
183#[derive(Clone, Debug, Serialize, Deserialize)]
184pub struct RelatedResourceEntry {
185    /// Datatype marker (always "resource-instance")
186    pub datatype: String,
187    /// Resource instance ID (UUID)
188    pub id: String,
189    /// Model class name (derived from graph name, e.g., "Person")
190    #[serde(rename = "type")]
191    pub resource_type: String,
192    /// Graph ID
193    #[serde(rename = "graphId")]
194    pub graph_id: String,
195    /// Display title (resource name)
196    #[serde(skip_serializing_if = "Option::is_none")]
197    pub title: Option<String>,
198    /// Resource descriptors (name, description, map_popup, slug)
199    #[serde(skip_serializing_if = "Option::is_none")]
200    pub descriptors: Option<StaticResourceDescriptors>,
201    /// Additional metadata
202    #[serde(skip_serializing_if = "Option::is_none")]
203    pub meta: Option<HashMap<String, serde_json::Value>>,
204}
205
206impl RelatedResourceEntry {
207    /// Create from a resource entry with optional model class name
208    pub fn from_resource_entry(entry: &ResourceEntry, model_class_name: Option<&str>) -> Self {
209        RelatedResourceEntry {
210            datatype: "resource-instance".to_string(),
211            id: entry.resourceinstanceid().to_string(),
212            resource_type: model_class_name
213                .map(|s| s.to_string())
214                .unwrap_or_else(|| entry.graph_id().to_string()),
215            graph_id: entry.graph_id().to_string(),
216            title: Some(entry.name().to_string()),
217            descriptors: entry.descriptors().cloned(),
218            meta: None,
219        }
220    }
221
222    /// Create from a resource summary with optional model class name
223    pub fn from_summary(summary: &StaticResourceSummary, model_class_name: Option<&str>) -> Self {
224        RelatedResourceEntry {
225            datatype: "resource-instance".to_string(),
226            id: summary.resourceinstanceid.clone(),
227            resource_type: model_class_name
228                .map(|s| s.to_string())
229                .unwrap_or_else(|| summary.graph_id.clone()),
230            graph_id: summary.graph_id.clone(),
231            title: Some(summary.name.clone()),
232            descriptors: summary.descriptors.clone(),
233            meta: if summary.metadata.is_empty() {
234                None
235            } else {
236                Some(
237                    summary
238                        .metadata
239                        .iter()
240                        .map(|(k, v)| (k.clone(), serde_json::Value::String(v.clone())))
241                        .collect(),
242                )
243            },
244        }
245    }
246
247    /// Get the resource instance ID
248    pub fn resourceinstanceid(&self) -> &str {
249        &self.id
250    }
251}
252
253impl From<&StaticResourceSummary> for RelatedResourceEntry {
254    fn from(summary: &StaticResourceSummary) -> Self {
255        RelatedResourceEntry::from_summary(summary, None)
256    }
257}
258
259impl From<&StaticResource> for RelatedResourceEntry {
260    fn from(resource: &StaticResource) -> Self {
261        let descriptors = &resource.resourceinstance.descriptors;
262        RelatedResourceEntry {
263            datatype: "resource-instance".to_string(),
264            id: resource.resourceinstance.resourceinstanceid.clone(),
265            resource_type: resource.resourceinstance.graph_id.clone(),
266            graph_id: resource.resourceinstance.graph_id.clone(),
267            title: Some(resource.resourceinstance.name.clone()),
268            descriptors: if descriptors.is_empty() {
269                None
270            } else {
271                Some(descriptors.clone())
272            },
273            meta: None,
274        }
275    }
276}
277
278impl From<RelatedResourceEntry> for StaticResourceSummary {
279    /// Hydrate a cache entry back to a full summary (with optional fields as None/empty)
280    fn from(entry: RelatedResourceEntry) -> Self {
281        StaticResourceSummary {
282            resourceinstanceid: entry.id,
283            graph_id: entry.graph_id,
284            name: entry.title.unwrap_or_default(),
285            descriptors: entry.descriptors,
286            metadata: HashMap::new(),
287            createdtime: None,
288            lastmodified: None,
289            publication_id: None,
290            principaluser_id: None,
291            legacyid: None,
292            graph_publication_id: None,
293        }
294    }
295}
296
297impl From<&ResourceEntry> for RelatedResourceEntry {
298    fn from(entry: &ResourceEntry) -> Self {
299        RelatedResourceEntry::from_resource_entry(entry, None)
300    }
301}
302
303/// Cache entry for resource-instance-list nodes.
304///
305/// Contains an array of resource entries to match TypeScript's ResourceInstanceListCacheEntry.
306#[derive(Clone, Debug, Serialize, Deserialize)]
307pub struct RelatedResourceListEntry {
308    /// Datatype marker (always "resource-instance-list")
309    pub datatype: String,
310    /// List of resource entries
311    #[serde(rename = "_")]
312    pub entries: Vec<RelatedResourceEntry>,
313    /// Additional metadata
314    #[serde(skip_serializing_if = "Option::is_none")]
315    pub meta: Option<HashMap<String, serde_json::Value>>,
316}
317
318impl RelatedResourceListEntry {
319    /// Create a new empty list entry
320    pub fn new() -> Self {
321        RelatedResourceListEntry {
322            datatype: "resource-instance-list".to_string(),
323            entries: Vec::new(),
324            meta: None,
325        }
326    }
327
328    /// Add an entry to the list
329    pub fn push(&mut self, entry: RelatedResourceEntry) {
330        self.entries.push(entry);
331    }
332
333    /// Check if the list is empty
334    pub fn is_empty(&self) -> bool {
335        self.entries.is_empty()
336    }
337}
338
339impl Default for RelatedResourceListEntry {
340    fn default() -> Self {
341        Self::new()
342    }
343}
344
345/// Cache entry that can be either a single resource or a list of resources.
346///
347/// Uses untagged serialization to match TypeScript's expected format:
348/// - resource-instance: `{datatype: "resource-instance", id, type, graphId, title}`
349/// - resource-instance-list: `{datatype: "resource-instance-list", _: [...], meta}`
350#[derive(Clone, Debug, Serialize, Deserialize)]
351#[serde(untagged)]
352pub enum CacheEntry {
353    /// Single resource reference (for resource-instance datatype)
354    Single(RelatedResourceEntry),
355    /// List of resource references (for resource-instance-list datatype)
356    List(RelatedResourceListEntry),
357}
358
359/// Cache structure for __cache field, matching TypeScript's getValueCache format.
360///
361/// Structure: { tileId: { nodeId: CacheEntry, ... }, ... }
362///
363/// This allows direct lookup in TypeScript via:
364/// `cacheEntries[tile.tileid][node.nodeid]`
365pub type ResourceCache = HashMap<String, HashMap<String, CacheEntry>>;
366
367/// Mutable context passed through resource-instance processing
368struct ProcessResourceContext<'a> {
369    cache: &'a mut ResourceCache,
370    enrich_relationships: bool,
371    source_resource_id: &'a str,
372    result: &'a mut PopulateCachesResult,
373}
374
375/// Reference to an unknown resource found during cache population
376#[derive(Clone, Debug, Serialize, Deserialize)]
377pub struct UnknownReference {
378    /// Resource that contains the reference
379    pub source_resource_id: String,
380    /// Node ID where the reference was found
381    pub node_id: String,
382    /// Node alias (if any)
383    pub node_alias: Option<String>,
384    /// The unknown resource ID that was referenced
385    pub referenced_id: String,
386}
387
388/// Result of populate_caches operation
389#[derive(Clone, Debug, Default, Serialize, Deserialize)]
390pub struct PopulateCachesResult {
391    /// References to resources not found in the registry
392    pub unknown_references: Vec<UnknownReference>,
393}
394
395impl PopulateCachesResult {
396    /// Check if there were any unknown references
397    pub fn has_unknown_references(&self) -> bool {
398        !self.unknown_references.is_empty()
399    }
400
401    /// Get error messages for unknown references
402    pub fn error_messages(&self) -> Vec<String> {
403        self.unknown_references
404            .iter()
405            .map(|r| {
406                let node_desc = r
407                    .node_alias
408                    .as_ref()
409                    .map(|a| format!("node '{}' ({})", a, r.node_id))
410                    .unwrap_or_else(|| format!("node '{}'", r.node_id));
411                format!(
412                    "Resource '{}': {} references unknown resource '{}'",
413                    r.source_resource_id, node_desc, r.referenced_id
414                )
415            })
416            .collect()
417    }
418}
419
420/// Entry in the resource registry - either full resource or summary only
421///
422/// This allows the registry to store minimal summaries (memory efficient) or
423/// full resources with tiles (for traversal), similar to staticStore's cacheMetadataOnly pattern.
424#[derive(Clone, Debug)]
425pub enum ResourceEntry {
426    /// Summary only - minimal memory, no tiles
427    Summary(Box<StaticResourceSummary>),
428    /// Full resource with tiles
429    Full(Box<StaticResource>),
430}
431
432impl ResourceEntry {
433    /// Get the resource instance ID
434    pub fn resourceinstanceid(&self) -> &str {
435        match self {
436            ResourceEntry::Summary(s) => &s.resourceinstanceid,
437            ResourceEntry::Full(r) => &r.resourceinstance.resourceinstanceid,
438        }
439    }
440
441    /// Get the graph ID
442    pub fn graph_id(&self) -> &str {
443        match self {
444            ResourceEntry::Summary(s) => &s.graph_id,
445            ResourceEntry::Full(r) => &r.resourceinstance.graph_id,
446        }
447    }
448
449    /// Get the resource name
450    pub fn name(&self) -> &str {
451        match self {
452            ResourceEntry::Summary(s) => &s.name,
453            ResourceEntry::Full(r) => &r.resourceinstance.name,
454        }
455    }
456
457    /// Get the resource descriptors (if available)
458    pub fn descriptors(&self) -> Option<&StaticResourceDescriptors> {
459        match self {
460            ResourceEntry::Summary(s) => s.descriptors.as_ref(),
461            ResourceEntry::Full(r) => Some(&r.resourceinstance.descriptors),
462        }
463    }
464
465    /// Check if this entry has tiles (is a full resource with tiles loaded)
466    pub fn has_tiles(&self) -> bool {
467        match self {
468            ResourceEntry::Summary(_) => false,
469            ResourceEntry::Full(r) => r.tiles.as_ref().map(|t| !t.is_empty()).unwrap_or(false),
470        }
471    }
472
473    /// Check if this is a full resource entry
474    pub fn is_full(&self) -> bool {
475        matches!(self, ResourceEntry::Full(_))
476    }
477
478    /// Get as full resource reference (if available)
479    pub fn as_full(&self) -> Option<&StaticResource> {
480        match self {
481            ResourceEntry::Full(r) => Some(r),
482            ResourceEntry::Summary(_) => None,
483        }
484    }
485
486    /// Get as full resource mutable reference (if available)
487    pub fn as_full_mut(&mut self) -> Option<&mut StaticResource> {
488        match self {
489            ResourceEntry::Full(r) => Some(r),
490            ResourceEntry::Summary(_) => None,
491        }
492    }
493
494    /// Convert to summary (extracts summary from full resource if needed)
495    pub fn to_summary(&self) -> StaticResourceSummary {
496        match self {
497            ResourceEntry::Summary(s) => *s.clone(),
498            ResourceEntry::Full(r) => r.to_summary(),
499        }
500    }
501
502    /// Convert to minimal cache entry
503    pub fn to_cache_entry(&self) -> RelatedResourceEntry {
504        match self {
505            ResourceEntry::Summary(s) => RelatedResourceEntry::from(s.as_ref()),
506            ResourceEntry::Full(r) => RelatedResourceEntry::from(r.as_ref()),
507        }
508    }
509}
510
511impl From<StaticResourceSummary> for ResourceEntry {
512    fn from(summary: StaticResourceSummary) -> Self {
513        ResourceEntry::Summary(Box::new(summary))
514    }
515}
516
517impl From<StaticResource> for ResourceEntry {
518    fn from(resource: StaticResource) -> Self {
519        ResourceEntry::Full(Box::new(resource))
520    }
521}
522
523/// Diagnostic stats for the resource registry
524#[derive(Clone, Debug, Serialize)]
525pub struct RegistryMemoryStats {
526    pub total: usize,
527    pub full_count: usize,
528    pub summary_count: usize,
529    pub total_tiles: usize,
530    pub cache_entries: usize,
531    /// Estimated bytes of __cache JSON across all full resources
532    pub cache_bytes_est: usize,
533    /// Estimated bytes of tile data JSON across all full resources
534    pub tile_bytes_est: usize,
535}
536
537/// In-memory registry of resources for relationship resolution and caching
538///
539/// Stores either full resources or summaries, allowing memory-efficient storage
540/// when only metadata is needed, with the ability to upgrade to full resources
541/// when tiles are required.
542///
543/// Used to:
544/// - Look up graph_id for referenced resources
545/// - Populate __cache on resources with related resource summaries
546/// - Enrich resource-instance tile data with ontologyProperty from node config
547/// - Cache full resources for traversal (like staticStore)
548#[derive(Clone, Debug, Default)]
549pub struct StaticResourceRegistry {
550    resources: HashMap<String, ResourceEntry>,
551}
552
553impl StaticResourceRegistry {
554    /// Create an empty registry
555    pub fn new() -> Self {
556        Self {
557            resources: HashMap::new(),
558        }
559    }
560
561    /// Get the graph_id for a resource
562    pub fn get_graph_id(&self, resource_id: &str) -> Option<&str> {
563        self.resources.get(resource_id).map(|e| e.graph_id())
564    }
565
566    /// Get the entry for a resource
567    pub fn get(&self, resource_id: &str) -> Option<&ResourceEntry> {
568        self.resources.get(resource_id)
569    }
570
571    /// Get a mutable entry for a resource
572    pub fn get_mut(&mut self, resource_id: &str) -> Option<&mut ResourceEntry> {
573        self.resources.get_mut(resource_id)
574    }
575
576    /// Get the full resource if available (returns None if only summary stored)
577    pub fn get_full(&self, resource_id: &str) -> Option<&StaticResource> {
578        self.resources.get(resource_id).and_then(|e| e.as_full())
579    }
580
581    /// Get a summary for a resource (works for both summary and full entries)
582    pub fn get_summary(&self, resource_id: &str) -> Option<StaticResourceSummary> {
583        self.resources.get(resource_id).map(|e| e.to_summary())
584    }
585
586    /// Check if a resource is known
587    pub fn contains(&self, resource_id: &str) -> bool {
588        self.resources.contains_key(resource_id)
589    }
590
591    /// Check if a resource has full data with tiles
592    pub fn has_full(&self, resource_id: &str) -> bool {
593        self.resources
594            .get(resource_id)
595            .map(|e| e.is_full())
596            .unwrap_or(false)
597    }
598
599    /// Get a breakdown of registry contents for memory diagnostics.
600    ///
601    /// Returns (total_entries, full_count, summary_count, total_tiles, total_cache_bytes)
602    /// where total_cache_bytes is an estimate of serialized __cache JSON size.
603    pub fn memory_stats(&self) -> RegistryMemoryStats {
604        let mut full_count: usize = 0;
605        let mut summary_count: usize = 0;
606        let mut total_tiles: usize = 0;
607        let mut cache_entries: usize = 0;
608
609        for entry in self.resources.values() {
610            match entry {
611                ResourceEntry::Full(r) => {
612                    full_count += 1;
613                    total_tiles += r.tiles.as_ref().map(|t| t.len()).unwrap_or(0);
614                    if r.cache.is_some() {
615                        cache_entries += 1;
616                    }
617                }
618                ResourceEntry::Summary(_) => {
619                    summary_count += 1;
620                }
621            }
622        }
623
624        RegistryMemoryStats {
625            total: self.resources.len(),
626            full_count,
627            summary_count,
628            total_tiles,
629            cache_entries,
630            cache_bytes_est: 0,
631            tile_bytes_est: 0,
632        }
633    }
634
635    /// Expensive version that estimates byte sizes by re-serializing.
636    /// Call once, not in a loop.
637    pub fn memory_stats_detailed(&self) -> RegistryMemoryStats {
638        let mut stats = self.memory_stats();
639        let mut cache_bytes_est: usize = 0;
640        let mut tile_bytes_est: usize = 0;
641
642        for entry in self.resources.values() {
643            if let ResourceEntry::Full(r) = entry {
644                if let Some(ref cache) = r.cache {
645                    cache_bytes_est += serde_json::to_string(cache).map(|s| s.len()).unwrap_or(0);
646                }
647                if let Some(ref tiles) = r.tiles {
648                    tile_bytes_est += serde_json::to_string(tiles).map(|s| s.len()).unwrap_or(0);
649                }
650            }
651        }
652
653        stats.cache_bytes_est = cache_bytes_est;
654        stats.tile_bytes_est = tile_bytes_est;
655        stats
656    }
657
658    /// Number of resources in the registry
659    pub fn len(&self) -> usize {
660        self.resources.len()
661    }
662
663    /// Check if registry is empty
664    pub fn is_empty(&self) -> bool {
665        self.resources.is_empty()
666    }
667
668    /// Add a single resource summary (won't overwrite full resources)
669    pub fn insert_summary(&mut self, summary: StaticResourceSummary) {
670        let id = summary.resourceinstanceid.clone();
671        // Don't downgrade full → summary
672        if !self.has_full(&id) {
673            self.resources
674                .insert(id, ResourceEntry::Summary(Box::new(summary)));
675        }
676    }
677
678    /// Add a single resource summary (legacy alias for insert_summary)
679    pub fn insert(&mut self, summary: StaticResourceSummary) {
680        self.insert_summary(summary);
681    }
682
683    /// Add a full resource (always overwrites, as it's more complete)
684    pub fn insert_full(&mut self, resource: StaticResource) {
685        let id = resource.resourceinstance.resourceinstanceid.clone();
686        self.resources
687            .insert(id, ResourceEntry::Full(Box::new(resource)));
688    }
689
690    /// Upgrade a summary to a full resource (if the resource exists)
691    pub fn upgrade_to_full(&mut self, resource: StaticResource) {
692        let id = resource.resourceinstance.resourceinstanceid.clone();
693        self.resources
694            .insert(id, ResourceEntry::Full(Box::new(resource)));
695    }
696
697    /// Merge resources into registry
698    ///
699    /// - If store_full is true, stores full resources (for traversal)
700    /// - If store_full is false, stores only summaries (memory efficient)
701    /// - If include_caches is true, also merges any __cache.relatedResources as summaries
702    pub fn merge_from_resources(
703        &mut self,
704        resources: &[StaticResource],
705        store_full: bool,
706        include_caches: bool,
707    ) {
708        for resource in resources {
709            // Register the resource itself
710            if store_full {
711                self.insert_full(resource.clone());
712            } else {
713                self.insert_summary(resource.to_summary());
714            }
715
716            // Merge from __cache if present and requested (always as summaries)
717            if include_caches {
718                if let Some(ref cache_json) = resource.cache {
719                    if let Ok(cache) = serde_json::from_value::<ResourceCache>(cache_json.clone()) {
720                        // Cache is now keyed by tileId -> nodeId -> entry
721                        for (_tile_id, node_entries) in cache {
722                            for (_node_id, cache_entry) in node_entries {
723                                // Extract entries based on cache entry type
724                                let entries: Vec<&RelatedResourceEntry> = match &cache_entry {
725                                    CacheEntry::Single(entry) => vec![entry],
726                                    CacheEntry::List(list) => list.entries.iter().collect(),
727                                };
728
729                                for entry in entries {
730                                    let id = entry.id.clone();
731                                    // Don't overwrite existing entries (first wins, and don't downgrade)
732                                    self.resources.entry(id).or_insert_with(|| {
733                                        ResourceEntry::Summary(Box::new(
734                                            StaticResourceSummary::from(entry.clone()),
735                                        ))
736                                    });
737                                }
738                            }
739                        }
740                    }
741                }
742            }
743        }
744    }
745
746    /// Iterate over all entries
747    pub fn iter(&self) -> impl Iterator<Item = (&String, &ResourceEntry)> {
748        self.resources.iter()
749    }
750
751    /// Iterate over all full resources
752    pub fn iter_full(&self) -> impl Iterator<Item = (&String, &StaticResource)> {
753        self.resources
754            .iter()
755            .filter_map(|(id, entry)| entry.as_full().map(|r| (id, r)))
756    }
757
758    /// Get all resource IDs
759    pub fn ids(&self) -> impl Iterator<Item = &String> {
760        self.resources.keys()
761    }
762
763    /// Populate __cache on resources with summaries for referenced resources
764    ///
765    /// Uses the graph to identify resource-instance/resource-instance-list nodes,
766    /// then populates cache entries for each referenced resource.
767    ///
768    /// If `enrich_relationships` is true, also adds ontologyProperty/inverseOntologyProperty
769    /// to tile data based on node config and the target resource's graph.
770    ///
771    /// Returns information about unknown references found during processing.
772    pub fn populate_caches(
773        &self,
774        resources: &mut [StaticResource],
775        graph: &super::StaticGraph,
776        enrich_relationships: bool,
777        strict: bool,
778        recompute_descriptors: bool,
779    ) -> Result<PopulateCachesResult, String> {
780        let mut result = PopulateCachesResult::default();
781
782        for resource in resources.iter_mut() {
783            let mut cache: ResourceCache = HashMap::new();
784            let resource_id = resource.resourceinstance.resourceinstanceid.clone();
785
786            if let Some(ref mut tiles) = resource.tiles {
787                for tile in tiles.iter_mut() {
788                    // Get tile ID (generate if missing)
789                    let tile_id = tile
790                        .tileid
791                        .clone()
792                        .unwrap_or_else(|| uuid::Uuid::new_v4().to_string());
793
794                    // Get all nodes in this nodegroup
795                    let nodes = graph.get_nodes_in_nodegroup(&tile.nodegroup_id);
796
797                    for node in nodes {
798                        // Only process resource-instance datatypes
799                        if node.datatype != "resource-instance"
800                            && node.datatype != "resource-instance-list"
801                        {
802                            continue;
803                        }
804
805                        // Get tile data for this node
806                        if let Some(data) = tile.data.get_mut(&node.nodeid) {
807                            let mut ctx = ProcessResourceContext {
808                                cache: &mut cache,
809                                enrich_relationships,
810                                source_resource_id: &resource_id,
811                                result: &mut result,
812                            };
813                            self.process_resource_instance_data(data, node, &tile_id, &mut ctx);
814                        }
815                    }
816                }
817            }
818
819            // Merge with existing cache if present
820            if !cache.is_empty() {
821                if let Some(ref existing_json) = resource.cache {
822                    if let Ok(existing) =
823                        serde_json::from_value::<ResourceCache>(existing_json.clone())
824                    {
825                        // Merge existing into new cache (new wins for conflicts)
826                        for (tile_id, node_entries) in existing {
827                            let tile_cache = cache.entry(tile_id).or_default();
828                            for (node_id, entry) in node_entries {
829                                tile_cache.entry(node_id).or_insert(entry);
830                            }
831                        }
832                    }
833                }
834                resource.cache = serde_json::to_value(&cache).ok();
835            }
836        }
837
838        // Recompute descriptors using the freshly-built caches
839        if recompute_descriptors {
840            let indexed = super::static_graph::IndexedGraph::new(graph.clone());
841            for resource in resources.iter_mut() {
842                let tiles = resource.tiles.as_deref().unwrap_or(&[]);
843                let cache: Option<ResourceCache> = resource
844                    .cache
845                    .as_ref()
846                    .and_then(|v| serde_json::from_value(v.clone()).ok());
847                let descriptors = indexed.build_descriptors_with_diagnostics(
848                    tiles,
849                    &mut Vec::new(),
850                    cache.as_ref(),
851                );
852                if let Some(ref name) = descriptors.name {
853                    if !name.is_empty() {
854                        resource.resourceinstance.name = name.clone();
855                    }
856                }
857                resource.resourceinstance.descriptors = descriptors;
858            }
859        }
860
861        if strict && result.has_unknown_references() {
862            let msgs = result.error_messages();
863            return Err(format!("Unknown resource references:\n{}", msgs.join("\n")));
864        }
865
866        Ok(result)
867    }
868
869    /// Process resource-instance data: populate cache and optionally enrich with relationship properties
870    fn process_resource_instance_data(
871        &self,
872        data: &mut serde_json::Value,
873        node: &super::StaticNode,
874        tile_id: &str,
875        ctx: &mut ProcessResourceContext<'_>,
876    ) {
877        let is_list = node.datatype == "resource-instance-list";
878
879        // For lists, collect all entries first
880        let mut list_entries: Vec<RelatedResourceEntry> = Vec::new();
881
882        // resource-instance data is an array of {resourceId: "..."}
883        if let Some(arr) = data.as_array_mut() {
884            for entry in arr.iter_mut() {
885                if let Some(resource_id) = entry.get("resourceId").and_then(|r| r.as_str()) {
886                    // Add to cache if we know this resource
887                    if let Some(resource_entry) = self.resources.get(resource_id) {
888                        // Get model class name from graph registry if available
889                        let model_class_name = crate::get_graph(resource_entry.graph_id())
890                            .and_then(|g| g.get_model_class_name());
891
892                        let related_entry = RelatedResourceEntry::from_resource_entry(
893                            resource_entry,
894                            model_class_name.as_deref(),
895                        );
896
897                        if is_list {
898                            // Collect entries for list
899                            list_entries.push(related_entry);
900                        } else {
901                            // Store single entry in cache keyed by tileId -> nodeId
902                            let tile_cache = ctx.cache.entry(tile_id.to_string()).or_default();
903                            tile_cache
904                                .insert(node.nodeid.clone(), CacheEntry::Single(related_entry));
905                        }
906
907                        // Enrich with relationship properties if requested
908                        if ctx.enrich_relationships {
909                            self.enrich_entry_with_relationship(entry, resource_entry, node);
910                        }
911                    } else {
912                        // Track unknown reference
913                        ctx.result.unknown_references.push(UnknownReference {
914                            source_resource_id: ctx.source_resource_id.to_string(),
915                            node_id: node.nodeid.clone(),
916                            node_alias: node.alias.clone(),
917                            referenced_id: resource_id.to_string(),
918                        });
919                    }
920                }
921            }
922        }
923
924        // For list datatype, store all collected entries as a list
925        if is_list && !list_entries.is_empty() {
926            let tile_cache = ctx.cache.entry(tile_id.to_string()).or_default();
927            tile_cache.insert(
928                node.nodeid.clone(),
929                CacheEntry::List(RelatedResourceListEntry {
930                    datatype: "resource-instance-list".to_string(),
931                    entries: list_entries,
932                    meta: None,
933                }),
934            );
935        }
936    }
937
938    /// Add ontologyProperty/inverseOntologyProperty to a resource-instance entry
939    /// based on node config and target resource's graph
940    fn enrich_entry_with_relationship(
941        &self,
942        entry: &mut serde_json::Value,
943        target_entry: &ResourceEntry,
944        node: &super::StaticNode,
945    ) {
946        // Skip if already has ontologyProperty
947        if entry.get("ontologyProperty").is_some() {
948            return;
949        }
950
951        // Get node config graphs array
952        let graphs = match node.config.get("graphs").and_then(|g| g.as_array()) {
953            Some(g) => g,
954            None => return,
955        };
956
957        // Find matching graph config for target resource's graph_id
958        let target_graph_id = target_entry.graph_id();
959        let graph_config = graphs.iter().find(|g| {
960            g.get("graphid")
961                .and_then(|id| id.as_str())
962                .map(|id| id == target_graph_id)
963                .unwrap_or(false)
964        });
965
966        let graph_config = match graph_config {
967            Some(g) => g,
968            None => return, // Target graph not configured for this node
969        };
970
971        // Determine which properties to use based on useOntologyRelationship
972        let use_ontology = graph_config
973            .get("useOntologyRelationship")
974            .and_then(|v| v.as_bool())
975            .unwrap_or(false);
976
977        let (ont_key, inv_key) = if use_ontology {
978            ("ontologyProperty", "inverseOntologyProperty")
979        } else {
980            ("relationshipConcept", "inverseRelationshipConcept")
981        };
982
983        // Add properties to entry (using ontologyProperty key for Arches compatibility)
984        if let Some(prop) = graph_config.get(ont_key).and_then(|v| v.as_str()) {
985            if !prop.is_empty() {
986                entry["ontologyProperty"] = serde_json::json!(prop);
987            }
988        }
989        if let Some(prop) = graph_config.get(inv_key).and_then(|v| v.as_str()) {
990            if !prop.is_empty() {
991                entry["inverseOntologyProperty"] = serde_json::json!(prop);
992            }
993        }
994    }
995
996    /// Build an index from resource IDs to node values for a given node.
997    ///
998    /// Efficiently iterates through tiles, filtering by nodegroup and extracting
999    /// values for the specified node.
1000    ///
1001    /// # Arguments
1002    /// * `graph` - The graph to use for node lookup
1003    /// * `node_identifier` - Node alias or node ID to extract values for
1004    ///
1005    /// # Returns
1006    /// * `Ok(HashMap<String, Vec<Value>>)` - Map from resource_id to list of values
1007    /// * `Err(String)` - Error if node not found
1008    pub fn get_node_values_index(
1009        &self,
1010        graph: &super::StaticGraph,
1011        node_identifier: &str,
1012    ) -> Result<HashMap<String, Vec<serde_json::Value>>, String> {
1013        // Find the node by alias or ID
1014        let node = graph
1015            .nodes
1016            .iter()
1017            .find(|n| n.alias.as_deref() == Some(node_identifier) || n.nodeid == node_identifier)
1018            .ok_or_else(|| {
1019                format!(
1020                    "Node '{}' not found in graph {}",
1021                    node_identifier, graph.graphid
1022                )
1023            })?;
1024
1025        let node_id = &node.nodeid;
1026        let nodegroup_id = node
1027            .nodegroup_id
1028            .as_ref()
1029            .ok_or_else(|| format!("Node '{}' has no nodegroup_id", node_identifier))?;
1030
1031        let mut index: HashMap<String, Vec<serde_json::Value>> = HashMap::new();
1032
1033        for (_, resource) in self.iter_full() {
1034            // Filter by graph
1035            if resource.resourceinstance.graph_id != graph.graphid {
1036                continue;
1037            }
1038
1039            let resource_id = &resource.resourceinstance.resourceinstanceid;
1040
1041            // Find tiles matching the nodegroup
1042            if let Some(ref tiles) = resource.tiles {
1043                for tile in tiles {
1044                    if tile.nodegroup_id.as_str() == nodegroup_id {
1045                        if let Some(value) = tile.data.get(node_id) {
1046                            index
1047                                .entry(resource_id.clone())
1048                                .or_default()
1049                                .push(value.clone());
1050                        }
1051                    }
1052                }
1053            }
1054        }
1055
1056        Ok(index)
1057    }
1058
1059    /// Build an inverted index from node display values to resource IDs.
1060    ///
1061    /// Uses the type serialization infrastructure to extract display strings,
1062    /// which handles built-in types (string, concept, domain-value, etc.) and
1063    /// extension-registered types (reference, etc.) via the global registry.
1064    ///
1065    /// # Arguments
1066    /// * `graph` - The graph to use for node lookup
1067    /// * `node_identifier` - Node alias or node ID to extract values for
1068    /// * `flatten_localized` - If true, extract string from localized values {"en": "value"}
1069    ///
1070    /// # Returns
1071    /// * `Ok(HashMap<String, Vec<String>>)` - Map from display value to list of resource_ids
1072    /// * `Err(String)` - Error if node not found
1073    pub fn get_value_to_resources_index(
1074        &self,
1075        graph: &super::StaticGraph,
1076        node_identifier: &str,
1077        flatten_localized: bool,
1078    ) -> Result<HashMap<String, Vec<String>>, String> {
1079        self.get_value_to_resources_index_with_context(
1080            graph,
1081            node_identifier,
1082            flatten_localized,
1083            None,
1084        )
1085    }
1086
1087    /// Build an inverted index from node display values to resource IDs,
1088    /// with an explicit serialization context for resolvers and extensions.
1089    ///
1090    /// # Arguments
1091    /// * `graph` - The graph to use for node lookup
1092    /// * `node_identifier` - Node alias or node ID to extract values for
1093    /// * `flatten_localized` - If true, extract string from localized values {"en": "value"}
1094    /// * `ctx` - Optional serialization context (resolvers, extension registry)
1095    ///
1096    /// # Returns
1097    /// * `Ok(HashMap<String, Vec<String>>)` - Map from display value to list of resource_ids
1098    /// * `Err(String)` - Error if node not found
1099    pub fn get_value_to_resources_index_with_context(
1100        &self,
1101        graph: &super::StaticGraph,
1102        node_identifier: &str,
1103        flatten_localized: bool,
1104        ctx: Option<&crate::type_serialization::SerializationContext>,
1105    ) -> Result<HashMap<String, Vec<String>>, String> {
1106        use crate::node_config::NodeConfigManager;
1107        use crate::type_serialization::{
1108            serialize_value, SerializationContext, SerializationOptions,
1109        };
1110
1111        // Find the node by alias or ID
1112        let node = graph
1113            .nodes
1114            .iter()
1115            .find(|n| n.alias.as_deref() == Some(node_identifier) || n.nodeid == node_identifier)
1116            .ok_or_else(|| {
1117                format!(
1118                    "Node '{}' not found in graph {}",
1119                    node_identifier, graph.graphid
1120                )
1121            })?;
1122
1123        let node_id = &node.nodeid;
1124        let datatype = &node.datatype;
1125        let nodegroup_id = node
1126            .nodegroup_id
1127            .as_ref()
1128            .ok_or_else(|| format!("Node '{}' has no nodegroup_id", node_identifier))?;
1129
1130        // Build node config from graph for this node's datatype
1131        let mut ncm = NodeConfigManager::new();
1132        ncm.build_from_graph(graph);
1133        let node_config = ncm.get(node_id);
1134
1135        let language = if flatten_localized { "en" } else { "" };
1136        let opts = SerializationOptions::display(language);
1137
1138        let empty_ctx = SerializationContext::empty();
1139        let base_ctx = ctx.unwrap_or(&empty_ctx);
1140        let ser_ctx = SerializationContext {
1141            node_config,
1142            external_resolver: base_ctx.external_resolver,
1143            resource_resolver: base_ctx.resource_resolver,
1144            extension_registry: base_ctx.extension_registry,
1145        };
1146
1147        let mut index: HashMap<String, Vec<String>> = HashMap::new();
1148
1149        for (_, resource) in self.iter_full() {
1150            // Filter by graph
1151            if resource.resourceinstance.graph_id != graph.graphid {
1152                continue;
1153            }
1154
1155            let resource_id = &resource.resourceinstance.resourceinstanceid;
1156
1157            // Find tiles matching the nodegroup
1158            if let Some(ref tiles) = resource.tiles {
1159                for tile in tiles {
1160                    if tile.nodegroup_id.as_str() == nodegroup_id {
1161                        if let Some(value) = tile.data.get(node_id) {
1162                            let result = serialize_value(datatype, value, &opts, Some(&ser_ctx));
1163                            if result.is_error() {
1164                                continue;
1165                            }
1166                            let keys = extract_display_keys(&result.value);
1167                            for k in keys {
1168                                index.entry(k).or_default().push(resource_id.clone());
1169                            }
1170                        }
1171                    }
1172                }
1173            }
1174        }
1175
1176        Ok(index)
1177    }
1178
1179    /// Extract values from one node in tiles where another node matches a filter.
1180    ///
1181    /// Both nodes must be in the same nodegroup. For each tile in that nodegroup,
1182    /// the filter node's display value is checked against `filter_values`. If any
1183    /// filter value appears in the display string, the extract node's raw JSON
1184    /// value is included in the results.
1185    ///
1186    /// # Arguments
1187    /// * `graph` - The graph for node lookup
1188    /// * `filter_node` - Alias or ID of the node to filter on
1189    /// * `filter_values` - Display values that pass the filter (matched as substrings of comma-separated tags)
1190    /// * `extract_node` - Alias or ID of the node whose values to extract
1191    /// * `flatten_localized` - If true, flatten localized values for the filter node
1192    ///
1193    /// # Returns
1194    /// A `Vec<serde_json::Value>` of raw values from the extract node for matching tiles.
1195    #[allow(clippy::too_many_arguments)]
1196    pub fn get_filtered_tile_values(
1197        &self,
1198        graph: &super::StaticGraph,
1199        filter_node: &str,
1200        filter_values: &[&str],
1201        extract_node: &str,
1202        flatten_localized: bool,
1203        ctx: Option<&crate::type_serialization::SerializationContext>,
1204        required_scope: Option<&str>,
1205    ) -> Result<Vec<serde_json::Value>, String> {
1206        use crate::node_config::NodeConfigManager;
1207        use crate::type_serialization::{
1208            serialize_value, SerializationContext, SerializationOptions,
1209        };
1210
1211        let find_node = |identifier: &str| {
1212            graph
1213                .nodes
1214                .iter()
1215                .find(|n| n.alias.as_deref() == Some(identifier) || n.nodeid == identifier)
1216                .ok_or_else(|| {
1217                    format!("Node '{}' not found in graph {}", identifier, graph.graphid)
1218                })
1219        };
1220
1221        let filter = find_node(filter_node)?;
1222        let extract = find_node(extract_node)?;
1223
1224        let filter_node_id = &filter.nodeid;
1225        let filter_datatype = &filter.datatype;
1226        let filter_nodegroup_id = filter
1227            .nodegroup_id
1228            .as_ref()
1229            .ok_or_else(|| format!("Node '{}' has no nodegroup_id", filter_node))?;
1230
1231        let extract_node_id = &extract.nodeid;
1232        let extract_nodegroup_id = extract
1233            .nodegroup_id
1234            .as_ref()
1235            .ok_or_else(|| format!("Node '{}' has no nodegroup_id", extract_node))?;
1236
1237        if filter_nodegroup_id != extract_nodegroup_id {
1238            return Err(format!(
1239                "Filter node '{}' (nodegroup {}) and extract node '{}' (nodegroup {}) are not in the same nodegroup",
1240                filter_node, filter_nodegroup_id, extract_node, extract_nodegroup_id
1241            ));
1242        }
1243
1244        let mut ncm = NodeConfigManager::new();
1245        ncm.build_from_graph(graph);
1246        let node_config = ncm.get(filter_node_id);
1247
1248        let language = if flatten_localized { "en" } else { "" };
1249        let opts = SerializationOptions::display(language);
1250
1251        let empty_ctx = SerializationContext::empty();
1252        let base_ctx = ctx.unwrap_or(&empty_ctx);
1253        let ser_ctx = SerializationContext {
1254            node_config,
1255            external_resolver: base_ctx.external_resolver,
1256            resource_resolver: base_ctx.resource_resolver,
1257            extension_registry: base_ctx.extension_registry,
1258        };
1259
1260        let mut results: Vec<serde_json::Value> = Vec::new();
1261
1262        for (_, resource) in self.iter_full() {
1263            if resource.resourceinstance.graph_id != graph.graphid {
1264                continue;
1265            }
1266
1267            // Check resource-level scope if required
1268            if let Some(scope) = required_scope {
1269                let has_scope = resource
1270                    .scopes
1271                    .as_ref()
1272                    .and_then(|s| s.as_array())
1273                    .map(|arr| arr.iter().any(|v| v.as_str() == Some(scope)))
1274                    .unwrap_or(false);
1275                if !has_scope {
1276                    continue;
1277                }
1278            }
1279
1280            if let Some(ref tiles) = resource.tiles {
1281                for tile in tiles {
1282                    if tile.nodegroup_id.as_str() != filter_nodegroup_id.as_str() {
1283                        continue;
1284                    }
1285
1286                    // Check filter node value
1287                    let matches = if let Some(filter_value) = tile.data.get(filter_node_id) {
1288                        let result =
1289                            serialize_value(filter_datatype, filter_value, &opts, Some(&ser_ctx));
1290                        if result.is_error() {
1291                            false
1292                        } else {
1293                            let keys = extract_display_keys(&result.value);
1294                            keys.iter().any(|display| {
1295                                let tags: Vec<&str> =
1296                                    display.split(',').map(|t| t.trim()).collect();
1297                                filter_values.iter().any(|fv| tags.contains(fv))
1298                            })
1299                        }
1300                    } else {
1301                        false
1302                    };
1303
1304                    if matches {
1305                        if let Some(extract_value) = tile.data.get(extract_node_id) {
1306                            results.push(extract_value.clone());
1307                        }
1308                    }
1309                }
1310            }
1311        }
1312
1313        Ok(results)
1314    }
1315}
1316
1317/// Extract string keys from a serialized display value.
1318///
1319/// Handles:
1320/// - String: returns the single string
1321/// - Array of strings: returns each string element
1322/// - Array of objects: tries to extract string values from each
1323/// - Null: returns empty
1324/// - Other: uses JSON representation as key
1325fn extract_display_keys(value: &serde_json::Value) -> Vec<String> {
1326    match value {
1327        serde_json::Value::String(s) => vec![s.clone()],
1328        serde_json::Value::Array(arr) => arr
1329            .iter()
1330            .filter_map(|v| match v {
1331                serde_json::Value::String(s) => Some(s.clone()),
1332                serde_json::Value::Null => None,
1333                other => Some(other.to_string()),
1334            })
1335            .collect(),
1336        serde_json::Value::Null => vec![],
1337        other => vec![other.to_string()],
1338    }
1339}
1340
1341impl crate::type_serialization::ResourceDisplayResolver for StaticResourceRegistry {
1342    fn resolve_resource_display(&self, resource_id: &str, _language: &str) -> Option<String> {
1343        let summary = self.get_summary(resource_id)?;
1344        // Try descriptors.name first, fall back to summary.name
1345        if let Some(ref descriptors) = summary.descriptors {
1346            if let Some(ref name) = descriptors.name {
1347                if !name.is_empty() {
1348                    return Some(name.clone());
1349                }
1350            }
1351        }
1352        if !summary.name.is_empty() {
1353            Some(summary.name)
1354        } else {
1355            None
1356        }
1357    }
1358}
1359
1360/// Result of merging multiple resources (single resourceinstanceid)
1361#[derive(Clone, Debug, Serialize, Deserialize)]
1362pub struct MergeResult {
1363    /// The merged resource with combined tiles
1364    pub resource: StaticResource,
1365    /// Warnings about duplicate tileids that were skipped
1366    pub warnings: Vec<String>,
1367}
1368
1369/// Result of batch merging resources grouped by resourceinstanceid
1370#[derive(Clone, Debug, Serialize, Deserialize)]
1371pub struct BatchMergeResult {
1372    /// Merged resources, one per unique resourceinstanceid
1373    pub resources: Vec<StaticResource>,
1374    /// All warnings from merging (including which resource had issues)
1375    pub warnings: Vec<String>,
1376    /// Fatal error message if strict mode aborted early
1377    #[serde(skip_serializing_if = "Option::is_none")]
1378    pub error: Option<String>,
1379}
1380
1381/// Merge multiple StaticResources into one
1382///
1383/// All resources must have the same `resourceinstanceid`. Tiles are concatenated,
1384/// with duplicate `tileid` values detected and skipped (first occurrence kept).
1385///
1386/// # Arguments
1387/// * `resources` - Vector of StaticResources to merge
1388///
1389/// # Returns
1390/// * `Ok(MergeResult)` - Merged resource and any warnings about duplicates
1391/// * `Err(String)` - Error if resources is empty or IDs don't match
1392///
1393/// # Example
1394/// ```ignore
1395/// let result = merge_resources(vec![resource1, resource2])?;
1396/// if !result.warnings.is_empty() {
1397///     eprintln!("Merge warnings: {:?}", result.warnings);
1398/// }
1399/// let merged = result.resource;
1400/// ```
1401pub fn merge_resources(resources: Vec<StaticResource>) -> Result<MergeResult, String> {
1402    if resources.is_empty() {
1403        return Err("No resources to merge".to_string());
1404    }
1405
1406    // Clone first resource's metadata before we consume the vector
1407    let first_instance = resources[0].resourceinstance.clone();
1408    let resource_id = first_instance.resourceinstanceid.clone();
1409
1410    // Verify all resources have the same resourceinstanceid
1411    for (i, r) in resources.iter().enumerate().skip(1) {
1412        if r.resourceinstance.resourceinstanceid != resource_id {
1413            return Err(format!(
1414                "Resource ID mismatch at index {}: expected '{}', found '{}'",
1415                i, resource_id, r.resourceinstance.resourceinstanceid
1416            ));
1417        }
1418    }
1419
1420    let mut seen_tileids: HashSet<String> = HashSet::new();
1421    let mut merged_tiles: Vec<StaticTile> = Vec::new();
1422    let mut warnings: Vec<String> = Vec::new();
1423    let mut merged_metadata: HashMap<String, String> = HashMap::new();
1424    let mut merged_cache: ResourceCache = ResourceCache::default();
1425    let mut merged_scopes: Option<serde_json::Value> = None;
1426    let mut first_scopes_index: Option<usize> = None;
1427
1428    for (i, resource) in resources.into_iter().enumerate() {
1429        // Merge tiles with duplicate detection
1430        if let Some(tiles) = resource.tiles {
1431            for tile in tiles {
1432                if let Some(ref tileid) = tile.tileid {
1433                    if seen_tileids.contains(tileid) {
1434                        continue;
1435                    }
1436                    seen_tileids.insert(tileid.clone());
1437                }
1438                merged_tiles.push(tile);
1439            }
1440        }
1441
1442        // Merge metadata dicts (later values override earlier ones)
1443        for (key, value) in resource.metadata {
1444            if let Some(existing) = merged_metadata.get(&key) {
1445                if existing != &value {
1446                    warnings.push(format!(
1447                        "Metadata key '{}' has conflicting values: '{}' vs '{}' (using latter)",
1448                        key, existing, value
1449                    ));
1450                }
1451            }
1452            merged_metadata.insert(key, value);
1453        }
1454
1455        // Handle scopes: warn if different, use first non-None value
1456        if let Some(scopes) = resource.scopes {
1457            match &merged_scopes {
1458                None => {
1459                    merged_scopes = Some(scopes);
1460                    first_scopes_index = Some(i);
1461                }
1462                Some(existing) if existing != &scopes => {
1463                    warnings.push(format!(
1464                        "Scopes mismatch: resource {} has different scopes than resource {} (using first)",
1465                        i, first_scopes_index.unwrap_or(0)
1466                    ));
1467                }
1468                _ => {}
1469            }
1470        }
1471
1472        // Merge cache entries (first wins for conflicts)
1473        // Cache is now keyed by tileId -> nodeId -> entry
1474        if let Some(cache_json) = resource.cache {
1475            if let Ok(cache) = serde_json::from_value::<ResourceCache>(cache_json) {
1476                for (tile_id, node_entries) in cache {
1477                    let tile_cache = merged_cache.entry(tile_id).or_default();
1478                    for (node_id, entry) in node_entries {
1479                        // First wins - don't overwrite existing entries
1480                        tile_cache.entry(node_id).or_insert(entry);
1481                    }
1482                }
1483            }
1484        }
1485    }
1486
1487    // Sort merged tiles by (nodegroup_id, sortorder) for consistent ordering
1488    merged_tiles.sort_by(|a, b| {
1489        let ng_cmp = a.nodegroup_id.cmp(&b.nodegroup_id);
1490        if ng_cmp != std::cmp::Ordering::Equal {
1491            return ng_cmp;
1492        }
1493        let a_sort = a.sortorder.unwrap_or(i32::MAX);
1494        let b_sort = b.sortorder.unwrap_or(i32::MAX);
1495        a_sort.cmp(&b_sort)
1496    });
1497
1498    // Convert merged_cache to JSON value if non-empty
1499    let final_cache = if merged_cache.is_empty() {
1500        None
1501    } else {
1502        serde_json::to_value(&merged_cache).ok()
1503    };
1504
1505    Ok(MergeResult {
1506        resource: StaticResource {
1507            resourceinstance: first_instance,
1508            tiles: Some(merged_tiles),
1509            metadata: merged_metadata,
1510            cache: final_cache,
1511            scopes: merged_scopes,
1512            tiles_loaded: Some(true),
1513        },
1514        warnings,
1515    })
1516}
1517
1518/// Parse a JSON string into a Vec of StaticResources.
1519///
1520/// Accepts multiple formats:
1521/// - An array of resources: `[{resourceinstance: ...}, ...]`
1522/// - A BusinessDataWrapper: `{business_data: {resources: [...]}}`
1523/// - A single resource: `{resourceinstance: ..., tiles: [...]}`
1524///
1525/// Takes ownership of the parsed JSON value internally to avoid cloning.
1526pub fn parse_resources_from_json_str(json_str: &str) -> Result<Vec<StaticResource>, String> {
1527    let value: serde_json::Value =
1528        serde_json::from_str(json_str).map_err(|e| format!("Failed to parse JSON: {}", e))?;
1529
1530    match value {
1531        serde_json::Value::Array(_) => serde_json::from_value(value)
1532            .map_err(|e| format!("Failed to parse resource array: {}", e)),
1533        serde_json::Value::Object(mut map) => {
1534            if let Some(bd) = map.remove("business_data") {
1535                if let serde_json::Value::Object(mut bd_map) = bd {
1536                    if let Some(resources) = bd_map.remove("resources") {
1537                        serde_json::from_value(resources)
1538                            .map_err(|e| format!("Failed to parse business_data.resources: {}", e))
1539                    } else {
1540                        Err("business_data missing 'resources' field".to_string())
1541                    }
1542                } else {
1543                    Err("business_data is not an object".to_string())
1544                }
1545            } else if map.contains_key("resourceinstance") {
1546                let resource: StaticResource =
1547                    serde_json::from_value(serde_json::Value::Object(map))
1548                        .map_err(|e| format!("Failed to parse as single resource: {}", e))?;
1549                Ok(vec![resource])
1550            } else {
1551                Err(
1552                    "Unrecognized format - expected array, BusinessDataWrapper, or StaticResource"
1553                        .to_string(),
1554                )
1555            }
1556        }
1557        _ => Err("Expected array or object".to_string()),
1558    }
1559}
1560
1561/// Stateful accumulator for memory-efficient incremental resource merging.
1562///
1563/// Accepts resources in chunks (as JSON strings or pre-parsed), merges them
1564/// progressively, and produces a final `BatchMergeResult`. Only the accumulated
1565/// `StaticResource` structs persist between chunks — input JSON strings can be
1566/// dropped by the caller after each `add_json` call.
1567///
1568/// Platform-agnostic: the caller controls where data comes from (files, network, etc.).
1569pub struct MergeAccumulator {
1570    accumulated: Vec<StaticResource>,
1571    warnings: Vec<String>,
1572    chunk_size: usize,
1573    strict: bool,
1574    pending: Vec<Vec<StaticResource>>,
1575    error: Option<String>,
1576}
1577
1578impl MergeAccumulator {
1579    pub fn new(chunk_size: usize, strict: bool) -> Self {
1580        Self {
1581            accumulated: Vec::new(),
1582            warnings: Vec::new(),
1583            chunk_size: if chunk_size == 0 { 10 } else { chunk_size },
1584            strict,
1585            pending: Vec::new(),
1586            error: None,
1587        }
1588    }
1589
1590    /// Feed a JSON string. The string is parsed and can be dropped by the caller afterward.
1591    /// Returns Err if parsing fails or a previous error was recorded.
1592    pub fn add_json(&mut self, json_str: &str) -> Result<(), String> {
1593        if let Some(ref e) = self.error {
1594            return Err(format!("Accumulator already in error state: {}", e));
1595        }
1596        let resources = parse_resources_from_json_str(json_str)?;
1597        self.pending.push(resources);
1598        if self.pending.len() >= self.chunk_size {
1599            self.flush()?;
1600        }
1601        Ok(())
1602    }
1603
1604    /// Feed pre-parsed resources directly.
1605    pub fn add_resources(&mut self, resources: Vec<StaticResource>) -> Result<(), String> {
1606        if let Some(ref e) = self.error {
1607            return Err(format!("Accumulator already in error state: {}", e));
1608        }
1609        self.pending.push(resources);
1610        if self.pending.len() >= self.chunk_size {
1611            self.flush()?;
1612        }
1613        Ok(())
1614    }
1615
1616    /// Merge pending batches into the accumulated result.
1617    fn flush(&mut self) -> Result<(), String> {
1618        if self.pending.is_empty() {
1619            return Ok(());
1620        }
1621
1622        let mut batches: Vec<Vec<StaticResource>> = Vec::new();
1623        if !self.accumulated.is_empty() {
1624            batches.push(std::mem::take(&mut self.accumulated));
1625        }
1626        batches.append(&mut self.pending);
1627
1628        let result = batch_merge_resources(batches, false, self.strict);
1629
1630        self.warnings.extend(result.warnings);
1631        if let Some(error) = result.error {
1632            self.error = Some(error.clone());
1633            self.accumulated = result.resources;
1634            return Err(error);
1635        }
1636        self.accumulated = result.resources;
1637        Ok(())
1638    }
1639
1640    /// Flush remaining pending batches, optionally recompute descriptors, and return the result.
1641    pub fn finish(mut self, recompute_descriptors: bool) -> BatchMergeResult {
1642        if let Err(e) = self.flush() {
1643            return BatchMergeResult {
1644                resources: self.accumulated,
1645                warnings: self.warnings,
1646                error: Some(e),
1647            };
1648        }
1649
1650        if recompute_descriptors && !self.accumulated.is_empty() {
1651            let result = batch_merge_resources(
1652                vec![std::mem::take(&mut self.accumulated)],
1653                true,
1654                self.strict,
1655            );
1656            self.warnings.extend(result.warnings);
1657            if let Some(ref error) = result.error {
1658                return BatchMergeResult {
1659                    resources: result.resources,
1660                    warnings: self.warnings,
1661                    error: Some(error.clone()),
1662                };
1663            }
1664            self.accumulated = result.resources;
1665        }
1666
1667        BatchMergeResult {
1668            resources: self.accumulated,
1669            warnings: self.warnings,
1670            error: None,
1671        }
1672    }
1673}
1674
1675/// Batch merge resources from multiple sources, grouping by resourceinstanceid
1676///
1677/// Takes multiple collections of resources (e.g., from different JSON files or API responses),
1678/// groups all resources by their `resourceinstanceid`, and merges each group.
1679///
1680/// # Arguments
1681/// * `resource_batches` - Vector of resource collections to merge
1682/// * `recompute_descriptors` - If true, recomputes descriptors from tiles after merging
1683///   using the graph from the registry (looked up by graph_id from the resource)
1684///
1685/// # Returns
1686/// * `BatchMergeResult` - Contains merged resources (one per unique ID) and all warnings
1687///
1688/// # Example
1689/// ```ignore
1690/// // Process disjoint subgraphs from multiple files
1691/// let batch1: Vec<StaticResource> = parse_file("part1.json");
1692/// let batch2: Vec<StaticResource> = parse_file("part2.json");
1693/// let result = batch_merge_resources(vec![batch1, batch2], true);
1694/// // result.resources contains one entry per unique resourceinstanceid
1695/// ```
1696pub fn batch_merge_resources(
1697    resource_batches: Vec<Vec<StaticResource>>,
1698    recompute_descriptors: bool,
1699    strict: bool,
1700) -> BatchMergeResult {
1701    use crate::registry::get_graph;
1702    use crate::IndexedGraph;
1703    use std::collections::BTreeMap;
1704
1705    // Group all resources by resourceinstanceid
1706    let mut grouped: BTreeMap<String, Vec<StaticResource>> = BTreeMap::new();
1707
1708    for batch in resource_batches {
1709        for resource in batch {
1710            let id = resource.resourceinstance.resourceinstanceid.clone();
1711            grouped.entry(id).or_default().push(resource);
1712        }
1713    }
1714
1715    let mut merged_resources = Vec::new();
1716    let mut all_warnings = Vec::new();
1717
1718    // Cache IndexedGraphs by graph_id to avoid rebuilding for each resource
1719    let mut indexed_graphs: BTreeMap<String, IndexedGraph> = BTreeMap::new();
1720
1721    // Merge each group
1722    for (resource_id, resources) in grouped {
1723        match merge_resources(resources) {
1724            Ok(result) => {
1725                // Prefix warnings with resource ID for clarity
1726                for warning in result.warnings {
1727                    all_warnings.push(format!("[{}] {}", resource_id, warning));
1728                }
1729
1730                let mut resource = result.resource;
1731                let graph_id = resource.resourceinstance.graph_id.clone();
1732
1733                // Get or create IndexedGraph for this graph_id (needed for both unification and descriptors)
1734                if !indexed_graphs.contains_key(&graph_id) {
1735                    if let Some(graph) = get_graph(&graph_id) {
1736                        indexed_graphs
1737                            .insert(graph_id.clone(), IndexedGraph::new((*graph).clone()));
1738                    }
1739                }
1740
1741                // Unify cardinality-1 tiles if we have the graph
1742                if let Some(indexed) = indexed_graphs.get(&graph_id) {
1743                    if let Some(ref mut tiles) = resource.tiles {
1744                        match unify_cardinality_one_tiles(tiles, indexed, strict) {
1745                            Ok(unify_warnings) => {
1746                                for warning in unify_warnings {
1747                                    all_warnings.push(format!("[{}] {}", resource_id, warning));
1748                                }
1749                            }
1750                            Err(e) => {
1751                                all_warnings.push(format!("[{}] Unify error: {}", resource_id, e));
1752                                if strict {
1753                                    return BatchMergeResult {
1754                                        resources: merged_resources,
1755                                        warnings: all_warnings,
1756                                        error: Some(format!("[{}] {}", resource_id, e)),
1757                                    };
1758                                }
1759                            }
1760                        }
1761                    }
1762                }
1763
1764                // Recompute descriptors if requested (graph already fetched above for unification)
1765                if recompute_descriptors {
1766                    if let Some(indexed) = indexed_graphs.get(&graph_id) {
1767                        // Compute descriptors from merged tiles with diagnostics
1768                        let tiles = resource.tiles.as_deref().unwrap_or(&[]);
1769                        let mut descriptor_warnings = Vec::new();
1770                        // Deserialize __cache so resource-instance placeholders can resolve to titles
1771                        let cache: Option<ResourceCache> = resource
1772                            .cache
1773                            .as_ref()
1774                            .and_then(|v| serde_json::from_value(v.clone()).ok());
1775                        let descriptors = indexed.build_descriptors_with_diagnostics(
1776                            tiles,
1777                            &mut descriptor_warnings,
1778                            cache.as_ref(),
1779                        );
1780
1781                        // Add descriptor warnings with resource context
1782                        for warning in descriptor_warnings {
1783                            all_warnings.push(format!("[{}] Descriptor: {}", resource_id, warning));
1784                        }
1785
1786                        // Update resource with computed descriptors
1787                        resource.resourceinstance.descriptors = descriptors.clone();
1788
1789                        // Update name from descriptors if available
1790                        if let Some(ref name) = descriptors.name {
1791                            if !name.is_empty() {
1792                                resource.resourceinstance.name = name.clone();
1793                            }
1794                        }
1795                    } else {
1796                        all_warnings.push(format!(
1797                            "[{}] Graph not found in registry for descriptor computation: {}",
1798                            resource_id, graph_id
1799                        ));
1800                    }
1801                }
1802
1803                merged_resources.push(resource);
1804            }
1805            Err(e) => {
1806                // This shouldn't happen since we grouped by ID, but handle gracefully
1807                all_warnings.push(format!("[{}] Merge error: {}", resource_id, e));
1808            }
1809        }
1810    }
1811
1812    BatchMergeResult {
1813        resources: merged_resources,
1814        warnings: all_warnings,
1815        error: None,
1816    }
1817}
1818
1819/// Type alias for tile data merge mapping: canonical_idx -> Vec<(source_tile_id, data)>
1820type TileDataMergeMap = HashMap<usize, Vec<(String, HashMap<String, serde_json::Value>)>>;
1821
1822/// Unify tiles for cardinality-1 nodegroups and update parenttile_id references.
1823///
1824/// When merging resources from multiple sources, cardinality-1 nodegroups may end up
1825/// with multiple tiles (one from each source). This function:
1826/// 1. Identifies cardinality-1 nodegroups with multiple tiles
1827/// 2. Keeps the first tile as canonical, merges data from duplicates
1828/// 3. Updates parenttile_id references in child tiles to point to the canonical tile
1829/// 4. Warns if there are conflicting data values
1830///
1831/// # Arguments
1832/// * `tiles` - Mutable reference to the tiles vector
1833/// * `indexed_graph` - The indexed graph for looking up nodegroup cardinality
1834///
1835/// # Returns
1836/// * Vector of warning messages about unified tiles and data conflicts
1837pub fn unify_cardinality_one_tiles(
1838    tiles: &mut Vec<StaticTile>,
1839    indexed_graph: &crate::IndexedGraph,
1840    strict: bool,
1841) -> Result<Vec<String>, String> {
1842    use std::collections::BTreeMap;
1843
1844    let mut warnings = Vec::new();
1845
1846    // Group tile indices by (nodegroup_id, parenttile_id).
1847    // Cardinality-1 means one tile per parent context, not one tile total —
1848    // tiles under different parent tiles are separate instances and must not be unified.
1849    let mut tiles_by_context: BTreeMap<(String, Option<String>), Vec<usize>> = BTreeMap::new();
1850    for (idx, tile) in tiles.iter().enumerate() {
1851        tiles_by_context
1852            .entry((tile.nodegroup_id.clone(), tile.parenttile_id.clone()))
1853            .or_default()
1854            .push(idx);
1855    }
1856
1857    // Build mapping of old_tile_id -> canonical_tile_id for cardinality-1 nodegroups
1858    let mut tile_redirect: HashMap<String, String> = HashMap::new();
1859    let mut tiles_to_remove: HashSet<usize> = HashSet::new();
1860    // Store data to merge: canonical_idx -> Vec<(source_tile_id, data)>
1861    let mut data_to_merge: TileDataMergeMap = HashMap::new();
1862
1863    for ((nodegroup_id, _parent_tile_id), tile_indices) in &tiles_by_context {
1864        if tile_indices.len() <= 1 {
1865            continue; // No unification needed
1866        }
1867
1868        // Check cardinality
1869        let nodegroup = match indexed_graph.graph.get_nodegroup_by_id(nodegroup_id) {
1870            Some(ng) => ng,
1871            None => continue,
1872        };
1873
1874        let is_single = nodegroup
1875            .cardinality
1876            .as_ref()
1877            .map(|c| c != "n")
1878            .unwrap_or(true);
1879
1880        if !is_single {
1881            continue; // cardinality-n, multiple tiles are allowed
1882        }
1883
1884        // Cardinality-1 with multiple tiles under the same parent - need to unify
1885        let canonical_idx = tile_indices[0];
1886        let canonical_tile_id = tiles[canonical_idx].tileid.clone();
1887
1888        for &idx in tile_indices.iter().skip(1) {
1889            let tile = &tiles[idx];
1890            let tile_id = tile
1891                .tileid
1892                .clone()
1893                .unwrap_or_else(|| format!("(index {})", idx));
1894
1895            // Record tile redirect
1896            if let Some(ref old_tile_id) = tile.tileid {
1897                if let Some(ref canon_id) = canonical_tile_id {
1898                    tile_redirect.insert(old_tile_id.clone(), canon_id.clone());
1899                }
1900            }
1901
1902            // Collect data to merge
1903            if !tile.data.is_empty() {
1904                data_to_merge
1905                    .entry(canonical_idx)
1906                    .or_default()
1907                    .push((tile_id, tile.data.clone()));
1908            }
1909
1910            tiles_to_remove.insert(idx);
1911        }
1912
1913        // Conflict and error warnings are emitted during the data merge below.
1914        // Routine unification (no conflicts) is silent.
1915    }
1916
1917    // Merge data into canonical tiles
1918    for (canonical_idx, sources) in data_to_merge {
1919        let canonical_tile = &mut tiles[canonical_idx];
1920        let canonical_tile_id = canonical_tile
1921            .tileid
1922            .clone()
1923            .unwrap_or_else(|| format!("(index {})", canonical_idx));
1924
1925        for (source_tile_id, source_data) in sources {
1926            for (key, value) in source_data {
1927                if let Some(existing) = canonical_tile.data.get(&key) {
1928                    if existing != &value {
1929                        let msg = format!(
1930                            "Data conflict in nodegroup '{}': key '{}' has different values in tiles '{}' and '{}'",
1931                            canonical_tile.nodegroup_id,
1932                            key,
1933                            canonical_tile_id,
1934                            source_tile_id
1935                        );
1936                        if strict {
1937                            return Err(msg);
1938                        }
1939                        warnings.push(format!("{} (keeping first)", msg));
1940                    }
1941                    // Keep existing value (first wins)
1942                } else {
1943                    // New key, add it
1944                    canonical_tile.data.insert(key, value);
1945                }
1946            }
1947        }
1948    }
1949
1950    // Update parenttile_id references
1951    for tile in tiles.iter_mut() {
1952        if let Some(ref old_parent_id) = tile.parenttile_id {
1953            if let Some(new_parent_id) = tile_redirect.get(old_parent_id) {
1954                tile.parenttile_id = Some(new_parent_id.clone());
1955            }
1956        }
1957    }
1958
1959    // Remove duplicate tiles (in reverse order to preserve indices)
1960    let mut indices: Vec<usize> = tiles_to_remove.into_iter().collect();
1961    indices.sort_by(|a, b| b.cmp(a)); // Reverse order
1962    for idx in indices {
1963        tiles.remove(idx);
1964    }
1965
1966    Ok(warnings)
1967}
1968
1969#[cfg(test)]
1970mod tests {
1971    use super::*;
1972
1973    #[test]
1974    fn test_static_resource_serialization() {
1975        let resource = StaticResource {
1976            resourceinstance: StaticResourceMetadata {
1977                descriptors: StaticResourceDescriptors::default(),
1978                graph_id: "test-graph".to_string(),
1979                name: "Test".to_string(),
1980                resourceinstanceid: "test-id".to_string(),
1981                publication_id: None,
1982                principaluser_id: None,
1983                legacyid: None,
1984                graph_publication_id: None,
1985                createdtime: None,
1986                lastmodified: None,
1987            },
1988            tiles: Some(vec![]),
1989            metadata: HashMap::new(),
1990            cache: None,
1991            scopes: None,
1992            tiles_loaded: None,
1993        };
1994
1995        let json = serde_json::to_string_pretty(&resource).unwrap();
1996        println!("StaticResource JSON:\n{}", json);
1997
1998        // Check that resourceinstance is nested
1999        let value: serde_json::Value = serde_json::from_str(&json).unwrap();
2000        assert!(
2001            value.get("resourceinstance").is_some(),
2002            "Should have nested resourceinstance"
2003        );
2004    }
2005
2006    fn make_test_resource(resource_id: &str, tile_ids: &[&str]) -> StaticResource {
2007        let tiles: Vec<StaticTile> = tile_ids
2008            .iter()
2009            .map(|id| StaticTile {
2010                tileid: Some(id.to_string()),
2011                nodegroup_id: "ng1".to_string(),
2012                resourceinstance_id: resource_id.to_string(),
2013                parenttile_id: None,
2014                data: HashMap::new(),
2015                provisionaledits: None,
2016                sortorder: None,
2017            })
2018            .collect();
2019
2020        StaticResource {
2021            resourceinstance: StaticResourceMetadata {
2022                descriptors: StaticResourceDescriptors::default(),
2023                graph_id: "test-graph".to_string(),
2024                name: "Test".to_string(),
2025                resourceinstanceid: resource_id.to_string(),
2026                publication_id: None,
2027                principaluser_id: None,
2028                legacyid: None,
2029                graph_publication_id: None,
2030                createdtime: None,
2031                lastmodified: None,
2032            },
2033            tiles: Some(tiles),
2034            metadata: HashMap::new(),
2035            cache: None,
2036            scopes: None,
2037            tiles_loaded: None,
2038        }
2039    }
2040
2041    #[test]
2042    fn test_merge_resources_basic() {
2043        let r1 = make_test_resource("res-1", &["tile-a", "tile-b"]);
2044        let r2 = make_test_resource("res-1", &["tile-c", "tile-d"]);
2045
2046        let result = merge_resources(vec![r1, r2]).unwrap();
2047
2048        assert_eq!(result.resource.resourceinstance.resourceinstanceid, "res-1");
2049        let tiles = result.resource.tiles.unwrap();
2050        assert_eq!(tiles.len(), 4);
2051        assert!(result.warnings.is_empty());
2052    }
2053
2054    #[test]
2055    fn test_merge_resources_duplicate_detection() {
2056        let r1 = make_test_resource("res-1", &["tile-a", "tile-b"]);
2057        let r2 = make_test_resource("res-1", &["tile-b", "tile-c"]); // tile-b is duplicate
2058
2059        let result = merge_resources(vec![r1, r2]).unwrap();
2060
2061        let tiles = result.resource.tiles.unwrap();
2062        assert_eq!(tiles.len(), 3); // tile-b counted once
2063        assert!(result.warnings.is_empty()); // duplicate skipping is silent
2064    }
2065
2066    #[test]
2067    fn test_merge_resources_id_mismatch() {
2068        let r1 = make_test_resource("res-1", &["tile-a"]);
2069        let r2 = make_test_resource("res-2", &["tile-b"]); // Different ID
2070
2071        let result = merge_resources(vec![r1, r2]);
2072
2073        assert!(result.is_err());
2074        assert!(result.unwrap_err().contains("mismatch"));
2075    }
2076
2077    #[test]
2078    fn test_merge_resources_empty() {
2079        let result = merge_resources(vec![]);
2080
2081        assert!(result.is_err());
2082        assert!(result.unwrap_err().contains("No resources"));
2083    }
2084
2085    #[test]
2086    fn test_merge_resources_preserves_cache() {
2087        // Create resources with cache entries
2088        let mut r1 = make_test_resource("res-1", &["tile-a"]);
2089        let mut r2 = make_test_resource("res-1", &["tile-b"]);
2090
2091        // Set up cache for r1: tileId -> nodeId -> entry
2092        let mut cache1: ResourceCache = HashMap::new();
2093        let mut tile_a_entries: HashMap<String, CacheEntry> = HashMap::new();
2094        tile_a_entries.insert(
2095            "node-1".to_string(),
2096            CacheEntry::Single(RelatedResourceEntry {
2097                datatype: "resource-instance".to_string(),
2098                id: "related-1".to_string(),
2099                resource_type: "TestModel".to_string(),
2100                graph_id: "graph-a".to_string(),
2101                title: Some("Related 1".to_string()),
2102                descriptors: None,
2103                meta: None,
2104            }),
2105        );
2106        tile_a_entries.insert(
2107            "node-2".to_string(),
2108            CacheEntry::Single(RelatedResourceEntry {
2109                datatype: "resource-instance".to_string(),
2110                id: "related-2".to_string(),
2111                resource_type: "TestModel".to_string(),
2112                graph_id: "graph-a".to_string(),
2113                title: Some("Related 2".to_string()),
2114                descriptors: None,
2115                meta: None,
2116            }),
2117        );
2118        cache1.insert("tile-a".to_string(), tile_a_entries);
2119        r1.cache = serde_json::to_value(&cache1).ok();
2120
2121        // Set up cache for r2 with overlapping tile/node and new entries
2122        let mut cache2: ResourceCache = HashMap::new();
2123        let mut tile_a_entries_2: HashMap<String, CacheEntry> = HashMap::new();
2124        tile_a_entries_2.insert(
2125            "node-2".to_string(),
2126            CacheEntry::Single(RelatedResourceEntry {
2127                datatype: "resource-instance".to_string(),
2128                id: "related-2".to_string(),
2129                resource_type: "TestModel".to_string(),
2130                graph_id: "graph-a".to_string(),
2131                title: Some("Related 2 - Different Name".to_string()), // Should be ignored (first wins)
2132                descriptors: None,
2133                meta: None,
2134            }),
2135        );
2136        cache2.insert("tile-a".to_string(), tile_a_entries_2);
2137
2138        let mut tile_b_entries: HashMap<String, CacheEntry> = HashMap::new();
2139        tile_b_entries.insert(
2140            "node-3".to_string(),
2141            CacheEntry::Single(RelatedResourceEntry {
2142                datatype: "resource-instance".to_string(),
2143                id: "related-3".to_string(),
2144                resource_type: "OtherModel".to_string(),
2145                graph_id: "graph-b".to_string(),
2146                title: Some("Related 3".to_string()),
2147                descriptors: None,
2148                meta: None,
2149            }),
2150        );
2151        cache2.insert("tile-b".to_string(), tile_b_entries);
2152        r2.cache = serde_json::to_value(&cache2).ok();
2153
2154        let result = merge_resources(vec![r1, r2]).unwrap();
2155
2156        // Check that cache was preserved
2157        assert!(result.resource.cache.is_some(), "Cache should be present");
2158
2159        let merged_cache: ResourceCache =
2160            serde_json::from_value(result.resource.cache.unwrap()).unwrap();
2161
2162        // Should have 2 tiles (tile-a, tile-b)
2163        assert_eq!(merged_cache.len(), 2);
2164        assert!(merged_cache.contains_key("tile-a"));
2165        assert!(merged_cache.contains_key("tile-b"));
2166
2167        // tile-a should have 2 entries (node-1, node-2)
2168        let tile_a = merged_cache.get("tile-a").unwrap();
2169        assert_eq!(tile_a.len(), 2);
2170        assert!(tile_a.contains_key("node-1"));
2171        assert!(tile_a.contains_key("node-2"));
2172
2173        // node-2 in tile-a should have the first resource's version (first wins)
2174        if let CacheEntry::Single(entry) = tile_a.get("node-2").unwrap() {
2175            assert_eq!(entry.title.as_deref(), Some("Related 2"));
2176        } else {
2177            panic!("Expected CacheEntry::Single");
2178        }
2179
2180        // tile-b should have 1 entry (node-3)
2181        let tile_b = merged_cache.get("tile-b").unwrap();
2182        assert_eq!(tile_b.len(), 1);
2183        assert!(tile_b.contains_key("node-3"));
2184    }
2185}