package cloud.nstart.terravault.ordinis.restapi.service.schema;

import com.fasterxml.jackson.databind.JsonNode;

import java.util.HashSet;
import java.util.Set;

/**
 * Detect whether a JSON-schema change is "breaking" — i.e. could invalidate
 * existing records or break downstream consumers.
 *
 * <p>Used by two callers with different stakes:
 *
 * <ol>
 *   <li>Approval gate ({@code DictionaryDefinitionService.updateSchema} when
 *       the dict has {@code approvalRequired=true}): breaking changes MUST
 *       go through the schema-draft workflow; non-breaking changes (add
 *       optional field, widen enum, edit description) may apply inline.
 *       <b>Security-sensitive</b> — false negatives here let a maker
 *       bypass review by mislabeling a real breaking change.</li>
 *   <li>Semver bump ({@code SchemaDraftService.computeNextVersion}):
 *       breaking → major, otherwise minor. False negatives just under-bump
 *       the version, no security impact.</li>
 * </ol>
 *
 * <p>Heuristic, not a complete JSON-schema diff. Conservative wins ties:
 * if we can't tell, return {@code true} (treat as breaking) — false
 * positives are recoverable (admin opens a draft that turns out to be
 * trivial) but false negatives are not.
 */
public final class SchemaCompatibility {

  private SchemaCompatibility() {
    // utility
  }

  /**
   * @return {@code true} if the transition from {@code oldSchema} to
   *         {@code newSchema} is breaking.
   *         {@code false} when both are null, equal, or differ only in
   *         compatible ways (added optional field, description / title /
   *         maximum widened, enum values added, etc.).
   */
  public static boolean isBreaking(JsonNode oldSchema, JsonNode newSchema) {
    if (oldSchema == null || newSchema == null) {
      // No baseline → can't classify. Caller decides: definitionService
      // never calls this when prevSchema is null (only on edits).
      return false;
    }
    if (oldSchema.equals(newSchema)) return false;

    // === required[] changes ===
    Set<String> oldReq = readStringArray(oldSchema.path("required"));
    Set<String> newReq = readStringArray(newSchema.path("required"));

    // Removed required key → breaking (consumers may have relied on the
    // field always being present).
    for (String k : oldReq) {
      if (!newReq.contains(k)) return true;
    }
    // Added required key → breaking (existing records lacking that field
    // fail validation).
    for (String k : newReq) {
      if (!oldReq.contains(k)) return true;
    }

    // === properties changes ===
    JsonNode oldProps = oldSchema.path("properties");
    JsonNode newProps = newSchema.path("properties");

    if (oldProps.isObject()) {
      var oldFieldNames = oldProps.fieldNames();
      while (oldFieldNames.hasNext()) {
        String fname = oldFieldNames.next();
        JsonNode oldField = oldProps.path(fname);
        JsonNode newField = newProps.path(fname);

        // Removed field → breaking (consumers + reverse references break).
        if (newField.isMissingNode()) return true;

        // type changed → breaking.
        JsonNode oldType = oldField.path("type");
        JsonNode newType = newField.path("type");
        if (!oldType.isMissingNode() && !newType.isMissingNode()
            && !oldType.equals(newType)) {
          return true;
        }

        // x-references changed → breaking (FK semantics — different target
        // dict invalidates existing values).
        JsonNode oldXref = oldField.path("x-references");
        JsonNode newXref = newField.path("x-references");
        if (!oldXref.isMissingNode() && !oldXref.equals(newXref)) {
          return true;
        }

        // Numeric narrowing: minimum increased OR maximum decreased.
        if (numericNarrowed(oldField, newField, "minimum", true)) return true;
        if (numericNarrowed(oldField, newField, "maximum", false)) return true;

        // Enum narrowed: any value present in old.enum missing in new.enum.
        Set<String> oldEnum = readStringArray(oldField.path("enum"));
        Set<String> newEnum = readStringArray(newField.path("enum"));
        if (!oldEnum.isEmpty() && !newEnum.containsAll(oldEnum)) return true;
      }
    }

    // Adding a NEW property (in newProps but not oldProps) is non-breaking
    // ONLY when it's not in newRequired. That case is already covered by
    // the "added required key" check above.

    return false;
  }

  private static Set<String> readStringArray(JsonNode arr) {
    Set<String> out = new HashSet<>();
    if (arr.isArray()) {
      for (JsonNode item : arr) {
        out.add(item.asText());
      }
    }
    return out;
  }

  /**
   * @param tighten {@code true} for "minimum" (narrowing means going UP),
   *                {@code false} for "maximum" (narrowing means going DOWN).
   */
  private static boolean numericNarrowed(
      JsonNode oldField, JsonNode newField, String key, boolean tighten) {
    JsonNode oldVal = oldField.path(key);
    JsonNode newVal = newField.path(key);
    if (!oldVal.isNumber() || !newVal.isNumber()) return false;
    double o = oldVal.doubleValue();
    double n = newVal.doubleValue();
    return tighten ? n > o : n < o;
  }
}
