AutoPagerizeJsonParser.java

package net.sasasin.sreader.service.autopagerize;

import java.time.OffsetDateTime;
import java.time.format.DateTimeParseException;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
import java.util.regex.Pattern;
import org.springframework.stereotype.Component;
import tools.jackson.core.JacksonException;
import tools.jackson.databind.DeserializationFeature;
import tools.jackson.databind.JsonNode;
import tools.jackson.databind.json.JsonMapper;

/**
 * Parses WeData AutoPagerize {@code items_all.json} (top-level array) into validated items.
 * Optional timestamp parse failures become warnings with NULL timestamps (rule is still accepted
 * when core fields are valid). Invalid {@code insertBefore} XPath is a warning only for
 * compatibility.
 */
@Component
public class AutoPagerizeJsonParser {

  private final JsonMapper jsonMapper;
  private final AutoPagerizeUrlPatternCompiler urlPatternCompiler;
  private final AutoPagerizeXPathSyntaxChecker xpathSyntaxChecker;

  public AutoPagerizeJsonParser(
      AutoPagerizeUrlPatternCompiler urlPatternCompiler,
      AutoPagerizeXPathSyntaxChecker xpathSyntaxChecker) {
    this.jsonMapper =
        JsonMapper.builder().enable(DeserializationFeature.FAIL_ON_TRAILING_TOKENS).build();
    this.urlPatternCompiler = urlPatternCompiler;
    this.xpathSyntaxChecker = xpathSyntaxChecker;
  }

  public List<AutoPagerizeParsedItem> parseArray(byte[] utf8Json) {
    Objects.requireNonNull(utf8Json, "utf8Json must not be null");
    final JsonNode root;
    try {
      root = jsonMapper.readTree(utf8Json);
    } catch (JacksonException e) {
      throw new AutoPagerizeImportException("Input is not valid UTF-8 JSON: " + e.getMessage(), e);
    }
    if (root == null || !root.isArray()) {
      throw new AutoPagerizeImportException("Top-level JSON value must be an array");
    }
    if (root.isEmpty()) {
      throw new AutoPagerizeImportException("Input array is empty");
    }

    List<AutoPagerizeParsedItem> items = new ArrayList<>(root.size());
    for (int i = 0; i < root.size(); i++) {
      items.add(parseItem(i, root.get(i)));
    }
    return List.copyOf(items);
  }

  private AutoPagerizeParsedItem parseItem(int ordinal, JsonNode node) {
    String rawItemJson = writeJson(node);
    List<AutoPagerizeIssue> errors = new ArrayList<>();
    List<AutoPagerizeIssue> warnings = new ArrayList<>();

    if (node == null || node.isNull() || !node.isObject()) {
      errors.add(new AutoPagerizeIssue("INVALID_ITEM", "Array element must be a JSON object"));
      return new AutoPagerizeParsedItem(
          ordinal,
          null,
          null,
          null,
          null,
          null,
          null,
          null,
          null,
          null,
          null,
          null,
          rawItemJson,
          errors,
          warnings);
    }

    String externalId = optionalTextOrNumber(node.get("id"));
    String resourceUrl = blankToNull(textOrNull(node.get("resource_url")));
    String name = blankToNull(textOrNull(node.get("name")));
    String createdBy = blankToNull(textOrNull(node.get("created_by")));
    OffsetDateTime createdAt =
        parseOptionalTimestamp(node.get("created_at"), "created_at", warnings);
    OffsetDateTime updatedAt =
        parseOptionalTimestamp(node.get("updated_at"), "updated_at", warnings);

    JsonNode data = node.get("data");
    String urlPattern = null;
    String nextLink = null;
    String pageElement = null;
    String insertBefore = null;
    String exampleUrl = null;

    if (data == null || data.isNull() || !data.isObject()) {
      errors.add(new AutoPagerizeIssue("MISSING_DATA", "data object is required"));
    } else {
      urlPattern = requiredText(data.get("url"), "url", errors);
      nextLink = requiredText(data.get("nextLink"), "nextLink", errors);
      pageElement = requiredText(data.get("pageElement"), "pageElement", errors);
      insertBefore = blankToNull(textOrNull(data.get("insertBefore")));
      exampleUrl = blankToNull(textOrNull(data.get("exampleUrl")));

      if (urlPattern != null) {
        String regexError = urlPatternCompiler.validateSyntax(urlPattern);
        if (regexError != null) {
          errors.add(
              new AutoPagerizeIssue(
                  "INVALID_URL_PATTERN", "data.url is not a valid regex: " + regexError));
        } else if (exampleUrl != null) {
          Pattern compiled = urlPatternCompiler.compile(urlPattern);
          if (!urlPatternCompiler.sampleMatches(compiled, exampleUrl)) {
            warnings.add(
                new AutoPagerizeIssue(
                    "EXAMPLE_URL_MISMATCH",
                    "data.exampleUrl does not match data.url (Matcher.find)"));
          }
        }
      }
      if (nextLink != null) {
        String xpathError = xpathSyntaxChecker.validateSyntax(nextLink);
        if (xpathError != null) {
          errors.add(
              new AutoPagerizeIssue(
                  "INVALID_NEXT_LINK_XPATH", "data.nextLink is not a valid XPath: " + xpathError));
        }
      }
      if (pageElement != null) {
        String xpathError = xpathSyntaxChecker.validateSyntax(pageElement);
        if (xpathError != null) {
          errors.add(
              new AutoPagerizeIssue(
                  "INVALID_PAGE_ELEMENT_XPATH",
                  "data.pageElement is not a valid XPath: " + xpathError));
        }
      }
      if (insertBefore != null) {
        String xpathError = xpathSyntaxChecker.validateSyntax(insertBefore);
        if (xpathError != null) {
          // Compatibility: keep the rule; runtime does not use insertBefore.
          warnings.add(
              new AutoPagerizeIssue(
                  "INVALID_INSERT_BEFORE_XPATH",
                  "data.insertBefore is not a valid XPath (stored, ignored at runtime): "
                      + xpathError));
        }
      }
    }

    return new AutoPagerizeParsedItem(
        ordinal,
        externalId,
        resourceUrl,
        name,
        createdBy,
        createdAt,
        updatedAt,
        urlPattern,
        nextLink,
        pageElement,
        insertBefore,
        exampleUrl,
        rawItemJson,
        errors,
        warnings);
  }

  private OffsetDateTime parseOptionalTimestamp(
      JsonNode node, String fieldName, List<AutoPagerizeIssue> warnings) {
    if (node == null || node.isNull()) {
      return null;
    }
    String text = textOrNull(node);
    if (text == null || text.isBlank()) {
      return null;
    }
    try {
      return OffsetDateTime.parse(text.trim());
    } catch (DateTimeParseException e) {
      warnings.add(
          new AutoPagerizeIssue(
              "INVALID_" + fieldName.toUpperCase(),
              fieldName + " is not a valid ISO-8601 offset datetime; stored as NULL"));
      return null;
    }
  }

  private static String requiredText(
      JsonNode node, String fieldName, List<AutoPagerizeIssue> errors) {
    if (node != null && !node.isNull() && !node.isMissingNode() && !node.isTextual()) {
      errors.add(
          new AutoPagerizeIssue(
              "INVALID_" + fieldName.toUpperCase() + "_TYPE",
              "data." + fieldName + " must be a JSON string"));
      return null;
    }
    String value = textOrNull(node);
    if (value == null || value.isBlank()) {
      errors.add(
          new AutoPagerizeIssue(
              "MISSING_" + fieldName.toUpperCase(), "data." + fieldName + " is required"));
      return null;
    }
    return value.trim();
  }

  private static String textOrNull(JsonNode node) {
    if (node == null || node.isNull() || node.isMissingNode()) {
      return null;
    }
    if (node.isTextual()) {
      return node.asString();
    }
    if (node.isNumber() || node.isBoolean()) {
      return node.asString();
    }
    return null;
  }

  private static String optionalTextOrNumber(JsonNode node) {
    String value = textOrNull(node);
    return blankToNull(value);
  }

  private static String blankToNull(String value) {
    if (value == null) {
      return null;
    }
    String trimmed = value.trim();
    return trimmed.isEmpty() ? null : trimmed;
  }

  private String writeJson(JsonNode node) {
    if (node == null || node.isNull()) {
      return "null";
    }
    try {
      return jsonMapper.writeValueAsString(node);
    } catch (JacksonException e) {
      throw new AutoPagerizeImportException("Failed to serialize raw item JSON", e);
    }
  }
}