ConflictHandler.java

package io.github.pojotools.flat2pojo.core.util;

import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.node.ObjectNode;
import java.util.Iterator;

/**
 * Utilities for handling field conflicts during flat-to-POJO conversion.
 *
 * <p>This class provides methods to resolve conflicts when multiple values are assigned to the same
 * field path according to different conflict policies.
 */
public final class ConflictHandler {
  private ConflictHandler() {}

  public static void writeScalarWithPolicy(
      final ObjectNode target,
      final String fieldName,
      final JsonNode incoming,
      final ConflictContext context) {
    final JsonNode existing = target.get(fieldName);

    if (existing == null || existing.isNull()) {
      target.set(fieldName, incoming);
      return;
    }

    final boolean shouldWrite = applyPolicy(existing, incoming, context);
    if (shouldWrite) {
      target.set(fieldName, incoming);
    }
  }

  private static boolean applyPolicy(
      final JsonNode existing, final JsonNode incoming, final ConflictContext context) {
    return switch (context.policy()) {
      case error -> {
        handleErrorPolicy(existing, incoming, context);
        yield true;
      }
      case firstWriteWins -> {
        handleFirstWriteWinsPolicy(existing, incoming, context);
        yield false;
      }
      case merge -> handleMergePolicy(existing, incoming, context);
      case lastWriteWins -> {
        handleLastWriteWinsPolicy(existing, incoming, context);
        yield true;
      }
    };
  }

  private static void handleErrorPolicy(
      final JsonNode existing, final JsonNode incoming, final ConflictContext context) {
    if (hasValueConflict(existing, incoming)) {
      final String message =
          "Conflict at '"
              + context.absolutePath()
              + "': existing="
              + existing
              + ", incoming="
              + incoming;
      context.reporterOptional().ifPresent(r -> r.warn(message));
      throw new RuntimeException(message);
    }
  }

  private static void handleFirstWriteWinsPolicy(
      final JsonNode existing, final JsonNode incoming, final ConflictContext context) {
    if (hasValueConflict(existing, incoming)) {
      context
          .reporterOptional()
          .ifPresent(
              r ->
                  r.warn(
                      "Field conflict resolved using firstWriteWins policy at '"
                          + context.absolutePath()
                          + "': kept existing="
                          + existing
                          + ", ignored incoming="
                          + incoming));
    }
  }

  private static boolean handleMergePolicy(
      final JsonNode existing, final JsonNode incoming, final ConflictContext context) {
    if (existing instanceof ObjectNode existingObject
        && incoming instanceof ObjectNode incomingObject) {
      deepMerge(existingObject, incomingObject);
      return false; // Don't write, already merged in place
    } else if (!existing.equals(incoming)) {
      context
          .reporterOptional()
          .ifPresent(
              r ->
                  r.warn(
                      "Cannot merge non-object values at '"
                          + context.absolutePath()
                          + "': existing="
                          + existing
                          + ", incoming="
                          + incoming
                          + ". Using lastWriteWins."));
    }
    return true; // Write for non-objects (fallback to lastWriteWins)
  }

  private static void handleLastWriteWinsPolicy(
      final JsonNode existing, final JsonNode incoming, final ConflictContext context) {
    if (hasValueConflict(existing, incoming)) {
      context
          .reporterOptional()
          .ifPresent(
              r ->
                  r.warn(
                      "Field conflict resolved using lastWriteWins policy at '"
                          + context.absolutePath()
                          + "': replaced existing="
                          + existing
                          + " with incoming="
                          + incoming));
    }
  }

  private static boolean hasValueConflict(final JsonNode existing, final JsonNode incoming) {
    return existing.isValueNode() && incoming.isValueNode() && !existing.equals(incoming);
  }

  public static void deepMerge(final ObjectNode target, final ObjectNode source) {
    final Iterator<String> fieldNames = source.fieldNames();
    while (fieldNames.hasNext()) {
      final String fieldName = fieldNames.next();
      final JsonNode sourceValue = source.get(fieldName);
      final JsonNode targetValue = target.get(fieldName);

      if (targetValue instanceof ObjectNode targetObject
          && sourceValue instanceof ObjectNode sourceObject) {
        deepMerge(targetObject, sourceObject);
      } else {
        target.set(fieldName, sourceValue);
      }
    }
  }
}