001/*
002 * SPDX-FileCopyrightText: none
003 * SPDX-License-Identifier: CC0-1.0
004 */
005
006package dev.metaschema.databind.io;
007
008import java.io.IOException;
009import java.util.ArrayList;
010import java.util.Collection;
011import java.util.HashMap;
012import java.util.HashSet;
013import java.util.List;
014import java.util.Map;
015import java.util.Set;
016import java.util.stream.Collectors;
017
018import dev.metaschema.core.model.IAssemblyInstance;
019import dev.metaschema.core.model.IBoundObject;
020import dev.metaschema.core.model.IChoiceInstance;
021import dev.metaschema.core.model.IFieldInstance;
022import dev.metaschema.core.model.IFlagInstance;
023import dev.metaschema.core.model.IModelInstance;
024import dev.metaschema.core.model.INamedModelInstanceAbsolute;
025import dev.metaschema.core.util.ObjectUtils;
026import dev.metaschema.databind.model.IBoundDefinitionModelAssembly;
027import dev.metaschema.databind.model.IBoundDefinitionModelComplex;
028import dev.metaschema.databind.model.IBoundProperty;
029import edu.umd.cs.findbugs.annotations.NonNull;
030import edu.umd.cs.findbugs.annotations.Nullable;
031
032/**
033 * Abstract base class for problem handlers that can validate required fields
034 * during deserialization.
035 */
036public abstract class AbstractProblemHandler implements IProblemHandler {
037  private final boolean validateRequiredFields;
038
039  /**
040   * Construct a new problem handler with default settings.
041   * <p>
042   * Required field validation is enabled by default.
043   */
044  protected AbstractProblemHandler() {
045    this(true);
046  }
047
048  /**
049   * Construct a new problem handler with the specified validation setting.
050   *
051   * @param validateRequiredFields
052   *          {@code true} to validate that required fields are present,
053   *          {@code false} to skip validation
054   */
055  protected AbstractProblemHandler(boolean validateRequiredFields) {
056    this.validateRequiredFields = validateRequiredFields;
057  }
058
059  /**
060   * Determine if required field validation is enabled.
061   *
062   * @return {@code true} if required fields should be validated, {@code false}
063   *         otherwise
064   */
065  protected boolean isValidateRequiredFields() {
066    return validateRequiredFields;
067  }
068
069  @Override
070  public void handleMissingInstances(
071      IBoundDefinitionModelComplex parentDefinition,
072      IBoundObject targetObject,
073      Collection<? extends IBoundProperty<?>> unhandledInstances) throws IOException {
074    // Delegate to the context-aware version with null context
075    handleMissingInstances(parentDefinition, targetObject, unhandledInstances, null);
076  }
077
078  @Override
079  public void handleMissingInstances(
080      IBoundDefinitionModelComplex parentDefinition,
081      IBoundObject targetObject,
082      Collection<? extends IBoundProperty<?>> unhandledInstances,
083      @Nullable ValidationContext context) throws IOException {
084    if (isValidateRequiredFields()) {
085      validateRequiredFields(parentDefinition, unhandledInstances, context);
086    }
087    applyDefaults(targetObject, unhandledInstances);
088  }
089
090  /**
091   * Validate that all required fields have values or defaults.
092   * <p>
093   * This method handles choice groups correctly: if an instance belongs to a
094   * choice and at least one sibling in that choice was provided, the instance is
095   * not considered missing.
096   *
097   * @param parentDefinition
098   *          the definition containing the unhandled instances
099   * @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}