1   /*
2    * SPDX-FileCopyrightText: none
3    * SPDX-License-Identifier: CC0-1.0
4    */
5   
6   package dev.metaschema.databind.io;
7   
8   import java.io.IOException;
9   import java.util.ArrayList;
10  import java.util.Collection;
11  import java.util.HashMap;
12  import java.util.HashSet;
13  import java.util.List;
14  import java.util.Map;
15  import java.util.Set;
16  import java.util.stream.Collectors;
17  
18  import dev.metaschema.core.model.IAssemblyInstance;
19  import dev.metaschema.core.model.IBoundObject;
20  import dev.metaschema.core.model.IChoiceInstance;
21  import dev.metaschema.core.model.IFieldInstance;
22  import dev.metaschema.core.model.IFlagInstance;
23  import dev.metaschema.core.model.IModelInstance;
24  import dev.metaschema.core.model.INamedModelInstanceAbsolute;
25  import dev.metaschema.core.util.ObjectUtils;
26  import dev.metaschema.databind.model.IBoundDefinitionModelAssembly;
27  import dev.metaschema.databind.model.IBoundDefinitionModelComplex;
28  import dev.metaschema.databind.model.IBoundProperty;
29  import edu.umd.cs.findbugs.annotations.NonNull;
30  import edu.umd.cs.findbugs.annotations.Nullable;
31  
32  /**
33   * Abstract base class for problem handlers that can validate required fields
34   * during deserialization.
35   */
36  public abstract class AbstractProblemHandler implements IProblemHandler {
37    private final boolean validateRequiredFields;
38  
39    /**
40     * Construct a new problem handler with default settings.
41     * <p>
42     * Required field validation is enabled by default.
43     */
44    protected AbstractProblemHandler() {
45      this(true);
46    }
47  
48    /**
49     * Construct a new problem handler with the specified validation setting.
50     *
51     * @param validateRequiredFields
52     *          {@code true} to validate that required fields are present,
53     *          {@code false} to skip validation
54     */
55    protected AbstractProblemHandler(boolean validateRequiredFields) {
56      this.validateRequiredFields = validateRequiredFields;
57    }
58  
59    /**
60     * Determine if required field validation is enabled.
61     *
62     * @return {@code true} if required fields should be validated, {@code false}
63     *         otherwise
64     */
65    protected boolean isValidateRequiredFields() {
66      return validateRequiredFields;
67    }
68  
69    @Override
70    public void handleMissingInstances(
71        IBoundDefinitionModelComplex parentDefinition,
72        IBoundObject targetObject,
73        Collection<? extends IBoundProperty<?>> unhandledInstances) throws IOException {
74      // Delegate to the context-aware version with null context
75      handleMissingInstances(parentDefinition, targetObject, unhandledInstances, null);
76    }
77  
78    @Override
79    public void handleMissingInstances(
80        IBoundDefinitionModelComplex parentDefinition,
81        IBoundObject targetObject,
82        Collection<? extends IBoundProperty<?>> unhandledInstances,
83        @Nullable ValidationContext context) throws IOException {
84      if (isValidateRequiredFields()) {
85        validateRequiredFields(parentDefinition, unhandledInstances, context);
86      }
87      applyDefaults(targetObject, unhandledInstances);
88    }
89  
90    /**
91     * Validate that all required fields have values or defaults.
92     * <p>
93     * This method handles choice groups correctly: if an instance belongs to a
94     * choice and at least one sibling in that choice was provided, the instance is
95     * not considered missing.
96     *
97     * @param parentDefinition
98     *          the definition containing the unhandled instances
99     * @param unhandledInstances
100    *          the collection of unhandled instances to validate
101    * @param context
102    *          the validation context with location and path information, may be
103    *          null
104    * @throws IOException
105    *           if a required field is missing and has no default value
106    */
107   protected void validateRequiredFields(
108       @NonNull IBoundDefinitionModelComplex parentDefinition,
109       @NonNull Collection<? extends IBoundProperty<?>> unhandledInstances,
110       @Nullable ValidationContext context) throws IOException {
111 
112     // Build a set of unhandled instance names for quick lookup
113     Set<String> unhandledNames = new HashSet<>();
114     for (IBoundProperty<?> instance : unhandledInstances) {
115       assert instance != null;
116       unhandledNames.add(getInstanceName(instance, context));
117     }
118 
119     // Build a map from instance name to its choice group (if any)
120     Map<String, IChoiceInstance> instanceToChoice = buildInstanceToChoiceMap(parentDefinition, context);
121 
122     // Collect missing required properties grouped by type
123     List<IBoundProperty<?>> missingFlags = new ArrayList<>();
124     List<IBoundProperty<?>> missingFields = new ArrayList<>();
125     List<IBoundProperty<?>> missingAssemblies = new ArrayList<>();
126 
127     for (IBoundProperty<?> instance : unhandledInstances) {
128       assert instance != null;
129       if (isRequiredAndMissingDefault(instance)) {
130         String instanceName = getInstanceName(instance, context);
131         IChoiceInstance choice = instanceToChoice.get(instanceName);
132 
133         if (choice != null) {
134           // Instance belongs to a choice group - check if any sibling was provided
135           if (!isChoiceSatisfied(choice, unhandledNames, context)) {
136             // All siblings in the choice are missing - report this as an error
137             addToTypeList(instance, missingFlags, missingFields, missingAssemblies);
138           }
139           // else: at least one sibling was provided, choice is satisfied
140         } else {
141           // Not in a choice group - normal required field check
142           addToTypeList(instance, missingFlags, missingFields, missingAssemblies);
143         }
144       }
145     }
146 
147     if (!missingFlags.isEmpty() || !missingFields.isEmpty() || !missingAssemblies.isEmpty()) {
148       throw new IOException(formatMissingPropertiesMessage(
149           parentDefinition, missingFlags, missingFields, missingAssemblies, context));
150     }
151   }
152 
153   /**
154    * Add an instance to the appropriate type-specific list.
155    *
156    * @param instance
157    *          the instance to categorize
158    * @param missingFlags
159    *          list for flag instances
160    * @param missingFields
161    *          list for field instances
162    * @param missingAssemblies
163    *          list for assembly instances
164    */
165   private static void addToTypeList(
166       @NonNull IBoundProperty<?> instance,
167       @NonNull List<IBoundProperty<?>> missingFlags,
168       @NonNull List<IBoundProperty<?>> missingFields,
169       @NonNull List<IBoundProperty<?>> missingAssemblies) {
170     if (instance instanceof IFlagInstance) {
171       missingFlags.add(instance);
172     } else if (instance instanceof IFieldInstance) {
173       missingFields.add(instance);
174     } else if (instance instanceof IAssemblyInstance) {
175       missingAssemblies.add(instance);
176     } else {
177       // Default to fields for unknown types
178       missingFields.add(instance);
179     }
180   }
181 
182   /**
183    * Format a comprehensive error message for missing required properties.
184    *
185    * @param parentDefinition
186    *          the parent definition containing the properties
187    * @param missingFlags
188    *          missing flag instances
189    * @param missingFields
190    *          missing field instances
191    * @param missingAssemblies
192    *          missing assembly instances
193    * @param context
194    *          the validation context, may be null
195    * @return a formatted error message
196    */
197   @NonNull
198   private static String formatMissingPropertiesMessage(
199       @NonNull IBoundDefinitionModelComplex parentDefinition,
200       @NonNull List<IBoundProperty<?>> missingFlags,
201       @NonNull List<IBoundProperty<?>> missingFields,
202       @NonNull List<IBoundProperty<?>> missingAssemblies,
203       @Nullable ValidationContext context) {
204 
205     StringBuilder message = new StringBuilder();
206     String parentName = getParentName(parentDefinition, context);
207     Format format = context != null ? context.getFormat() : Format.JSON;
208 
209     int totalMissing = missingFlags.size() + missingFields.size() + missingAssemblies.size();
210 
211     if (totalMissing == 1) {
212       // Single missing property - use specific format
213       IBoundProperty<?> missing = ObjectUtils.notNull(!missingFlags.isEmpty() ? missingFlags.get(0)
214           : !missingFields.isEmpty() ? missingFields.get(0)
215               : missingAssemblies.get(0));
216       String type = getPropertyTypeName(missing, format, false);
217       String name = getInstanceName(missing, context);
218       message.append(String.format("Missing required %s '%s' in '%s'", type, name, parentName));
219     } else if (hasSingleType(missingFlags, missingFields, missingAssemblies)) {
220       // Multiple properties of single type
221       List<IBoundProperty<?>> list = !missingFlags.isEmpty() ? missingFlags
222           : !missingFields.isEmpty() ? missingFields
223               : missingAssemblies;
224       String type = getPropertyTypeName(ObjectUtils.notNull(list.get(0)), format, true);
225       String names = formatNameList(list, context);
226       message.append(String.format("Missing required %s in '%s': %s", type, parentName, names));
227     } else {
228       // Multiple properties of different types
229       message.append(String.format("Missing required properties in '%s':", parentName));
230       if (!missingFlags.isEmpty()) {
231         message.append("\n  ").append(getFormatPropertyGroupLabel(true, format))
232             .append(": ").append(formatNameList(missingFlags, context));
233       }
234       if (!missingFields.isEmpty()) {
235         message.append("\n  ").append(getFormatPropertyGroupLabel(false, format))
236             .append(": ").append(formatNameList(missingFields, context));
237       }
238       if (!missingAssemblies.isEmpty()) {
239         message.append("\n  ").append(getFormatPropertyGroupLabel(false, format))
240             .append(": ").append(formatNameList(missingAssemblies, context));
241       }
242     }
243 
244     // Add location and path context
245     if (context != null) {
246       String location = context.formatLocation();
247       if (!location.isEmpty()) {
248         message.append("\n  Location: ").append(location);
249       }
250       String path = context.getPath();
251       if (!"/".equals(path) && !path.isEmpty()) {
252         message.append("\n  Path: ").append(path);
253       }
254     }
255 
256     return ObjectUtils.notNull(message.toString());
257   }
258 
259   /**
260    * Get the user-friendly name for a property type in the given format.
261    * <p>
262    * This method maps Metaschema concepts to format-appropriate terminology:
263    * <ul>
264    * <li>XML: flags are "attribute", fields/assemblies are "element"</li>
265    * <li>JSON: all properties are "property"</li>
266    * <li>YAML: all properties are "property"</li>
267    * </ul>
268    *
269    * @param isFlag
270    *          {@code true} if the property is a flag instance
271    * @param format
272    *          the format being parsed
273    * @param plural
274    *          {@code true} for plural form, {@code false} for singular
275    * @return the user-friendly type name
276    */
277   @NonNull
278   private static String getFormatPropertyTypeName(boolean isFlag, @NonNull Format format, boolean plural) {
279     switch (format) {
280     case XML:
281       if (isFlag) {
282         return plural ? "attributes" : "attribute";
283       }
284       return plural ? "elements" : "element";
285     case JSON:
286     case YAML:
287       return plural ? "properties" : "property";
288     default:
289       // Fallback for any future formats - use generic "property"
290       return plural ? "properties" : "property";
291     }
292   }
293 
294   /**
295    * Get the user-friendly label for a group of properties in the given format.
296    * <p>
297    * Used when listing multiple properties of the same type in error messages.
298    * Delegates to {@link #getFormatPropertyTypeName(boolean, Format, boolean)} and
299    * capitalizes the result.
300    *
301    * @param isFlags
302    *          {@code true} if listing flag instances
303    * @param format
304    *          the format being parsed
305    * @return the plural label (e.g., "Attributes", "Elements", "Properties")
306    */
307   @NonNull
308   private static String getFormatPropertyGroupLabel(boolean isFlags, @NonNull Format format) {
309     String typeName = getFormatPropertyTypeName(isFlags, format, true);
310     return Character.toUpperCase(typeName.charAt(0)) + typeName.substring(1);
311   }
312 
313   /**
314    * Check if only one type list has entries.
315    */
316   private static boolean hasSingleType(
317       List<IBoundProperty<?>> flags,
318       List<IBoundProperty<?>> fields,
319       List<IBoundProperty<?>> assemblies) {
320     int nonEmpty = 0;
321     if (!flags.isEmpty()) {
322       nonEmpty++;
323     }
324     if (!fields.isEmpty()) {
325       nonEmpty++;
326     }
327     if (!assemblies.isEmpty()) {
328       nonEmpty++;
329     }
330     return nonEmpty == 1;
331   }
332 
333   /**
334    * Get the property type name for error messages in format-appropriate terms.
335    * <p>
336    * Delegates to {@link #getFormatPropertyTypeName(boolean, Format, boolean)}
337    * based on whether the instance is a flag.
338    *
339    * @param instance
340    *          the property instance
341    * @param format
342    *          the format being parsed
343    * @param plural
344    *          {@code true} for plural form, {@code false} for singular
345    * @return the user-friendly type name appropriate for the format
346    */
347   @NonNull
348   private static String getPropertyTypeName(
349       @NonNull IBoundProperty<?> instance,
350       @NonNull Format format,
351       boolean plural) {
352     boolean isFlag = instance instanceof IFlagInstance;
353     return getFormatPropertyTypeName(isFlag, format, plural);
354   }
355 
356   /**
357    * Format a list of property names as a comma-separated string.
358    */
359   @NonNull
360   private static String formatNameList(
361       @NonNull List<IBoundProperty<?>> instances,
362       @Nullable ValidationContext context) {
363     return ObjectUtils.notNull(instances.stream()
364         .map(i -> getInstanceName(ObjectUtils.notNull(i), context))
365         .collect(Collectors.joining(", ")));
366   }
367 
368   /**
369    * Get the parent definition name for error messages.
370    *
371    * @param parentDefinition
372    *          the parent definition
373    * @param context
374    *          the validation context, may be null
375    * @return the effective name of the parent
376    */
377   @NonNull
378   private static String getParentName(
379       @NonNull IBoundDefinitionModelComplex parentDefinition,
380       @Nullable ValidationContext context) {
381     // Use effective name which is format-appropriate
382     return parentDefinition.getEffectiveName();
383   }
384 
385   /**
386    * Build a map from instance name to its containing choice instance.
387    *
388    * @param parentDefinition
389    *          the parent definition to examine
390    * @param context
391    *          the validation context, may be null
392    * @return a map of instance names to their choice groups, empty if no choices
393    */
394   @NonNull
395   private static Map<String, IChoiceInstance> buildInstanceToChoiceMap(
396       @NonNull IBoundDefinitionModelComplex parentDefinition,
397       @Nullable ValidationContext context) {
398     Map<String, IChoiceInstance> result = new HashMap<>();
399 
400     if (parentDefinition instanceof IBoundDefinitionModelAssembly) {
401       IBoundDefinitionModelAssembly assembly = (IBoundDefinitionModelAssembly) parentDefinition;
402       for (IChoiceInstance choice : assembly.getChoiceInstances()) {
403         for (INamedModelInstanceAbsolute modelInstance : choice.getNamedModelInstances()) {
404           // Use effective name for format-appropriate matching
405           result.put(modelInstance.getEffectiveName(), choice);
406         }
407       }
408     }
409 
410     return result;
411   }
412 
413   /**
414    * Check if a choice is satisfied.
415    * <p>
416    * A choice is satisfied if:
417    * <ul>
418    * <li>At least one alternative was provided, OR</li>
419    * <li>The choice is optional (minOccurs = 0) and no alternative is required
420    * </li>
421    * </ul>
422    *
423    * @param choice
424    *          the choice to check
425    * @param unhandledNames
426    *          the set of instance names that were NOT provided
427    * @param context
428    *          the validation context, may be null
429    * @return {@code true} if the choice requirements are satisfied, {@code false}
430    *         if a required alternative is missing
431    */
432   private static boolean isChoiceSatisfied(
433       @NonNull IChoiceInstance choice,
434       @NonNull Set<String> unhandledNames,
435       @Nullable ValidationContext context) {
436     // Check if any alternative was provided
437     for (INamedModelInstanceAbsolute modelInstance : choice.getNamedModelInstances()) {
438       // Use effective name for format-appropriate matching
439       String name = modelInstance.getEffectiveName();
440       if (!unhandledNames.contains(name)) {
441         // This sibling was provided (not in unhandled list)
442         return true;
443       }
444     }
445 
446     // All siblings are in the unhandled list - check if choice is optional
447     // If choice.getMinOccurs() == 0, having no selection is valid
448     return choice.getMinOccurs() == 0;
449   }
450 
451   /**
452    * Determine if the given instance is required and has no default value.
453    *
454    * @param instance
455    *          the instance to check
456    * @return {@code true} if the instance is required and has no default value
457    */
458   private static boolean isRequiredAndMissingDefault(@NonNull IBoundProperty<?> instance) {
459     // Check if the instance has a default value
460     Object defaultValue = instance.getResolvedDefaultValue();
461     if (defaultValue != null) {
462       // Has a default value, so it's not "missing"
463       return false;
464     }
465 
466     // Check if the instance is required
467     if (instance instanceof IFlagInstance) {
468       return ((IFlagInstance) instance).isRequired();
469     } else if (instance instanceof IModelInstance) {
470       return ((IModelInstance) instance).getMinOccurs() > 0;
471     }
472 
473     // Unknown instance type, don't require it
474     return false;
475   }
476 
477   /**
478    * Get a human-readable name for the instance.
479    * <p>
480    * Uses {@code getEffectiveName()} when available to return the
481    * format-appropriate name (XML element/attribute name for XML, JSON property
482    * name for JSON). Falls back to {@code getJsonName()} if effective name is not
483    * available.
484    *
485    * @param instance
486    *          the instance to get the name for
487    * @param context
488    *          the validation context, may be null
489    * @return the instance name
490    */
491   @NonNull
492   private static String getInstanceName(
493       @NonNull IBoundProperty<?> instance,
494       @Nullable ValidationContext context) {
495     // Check for specific instance types that have getEffectiveName()
496     if (instance instanceof IFlagInstance) {
497       return ((IFlagInstance) instance).getEffectiveName();
498     } else if (instance instanceof IFieldInstance) {
499       return ((IFieldInstance) instance).getEffectiveName();
500     } else if (instance instanceof IAssemblyInstance) {
501       return ((IAssemblyInstance) instance).getEffectiveName();
502     }
503     // Fall back to JSON name for other types
504     return instance.getJsonName();
505   }
506 
507   /**
508    * A utility method for applying default values for the provided
509    * {@code unhandledInstances}.
510    *
511    * @param targetObject
512    *          the Java object to apply default values to
513    * @param unhandledInstances
514    *          the collection of unhandled instances to assign default values for
515    * @throws IOException
516    *           if an error occurred while determining the default value for an
517    *           instance
518    */
519   protected static void applyDefaults(
520       @NonNull Object targetObject,
521       @NonNull Collection<? extends IBoundProperty<?>> unhandledInstances) throws IOException {
522     for (IBoundProperty<?> instance : unhandledInstances) {
523       assert instance != null;
524       Object value = instance.getResolvedDefaultValue();
525       if (value != null) {
526         instance.setValue(targetObject, value);
527       }
528     }
529   }
530 }