WorkflowSemanticProjectionValues.java

package org.hammer.audio.infrastructure.workflow.search;

import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.Base64;
import java.util.Collection;
import java.util.Comparator;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.stream.Collectors;
import org.hammer.audio.workflow.Node;
import org.hammer.audio.workflow.Workflow;
import org.hammer.audio.workflow.dsl.WorkflowDslParser;
import org.hammer.audio.workflow.history.WorkflowSemanticProperty;
import org.hammer.audio.workflow.store.WorkflowSnapshot;

/** Deterministically extracted workflow-domain values used by the disposable semantic index. */
record WorkflowSemanticProjectionValues(
    String workflowId,
    String workflowName,
    List<String> nodeIds,
    List<String> nodeTypes,
    List<String> nodeLabels,
    List<String> propertyKeys,
    List<String> propertyValues,
    List<String> propertyPairs) {

  private static final String ENCODING_PREFIX = "v1_";
  private static final Base64.Encoder ENCODER = Base64.getUrlEncoder().withoutPadding();
  private static final Base64.Decoder DECODER = Base64.getUrlDecoder();

  WorkflowSemanticProjectionValues {
    workflowId = requireNotBlank(workflowId, "workflowId");
    workflowName = requireNotBlank(workflowName, "workflowName");
    nodeIds = normalizedValues(nodeIds);
    nodeTypes = normalizedValues(nodeTypes);
    nodeLabels = normalizedValues(nodeLabels);
    propertyKeys = normalizedValues(propertyKeys);
    propertyValues = normalizedValues(propertyValues);
    propertyPairs = normalizedValues(propertyPairs);
  }

  static WorkflowSemanticProjectionValues from(
      WorkflowSnapshot snapshot, WorkflowDslParser parser) {
    Objects.requireNonNull(snapshot, "snapshot");
    Workflow workflow = Objects.requireNonNull(parser, "parser").parse(snapshot.dslText());
    if (!snapshot.workflowId().equals(workflow.id())) {
      throw new IllegalArgumentException(
          "workflow.id and workflow DSL id differ: "
              + snapshot.workflowId()
              + " != "
              + workflow.id());
    }

    List<Node> nodes = workflow.nodes().stream().sorted(Comparator.comparing(Node::id)).toList();
    List<String> propertyKeys = new ArrayList<>();
    List<String> propertyValues = new ArrayList<>();
    List<String> propertyPairs = new ArrayList<>();
    addMetadata(workflow.metadata().entries(), propertyKeys, propertyValues, propertyPairs);
    for (Node node : nodes) {
      addMetadata(node.metadata().entries(), propertyKeys, propertyValues, propertyPairs);
    }

    return new WorkflowSemanticProjectionValues(
        workflow.id(),
        workflow.name(),
        nodes.stream().map(Node::id).toList(),
        nodes.stream().map(Node::type).toList(),
        nodes.stream().map(Node::label).toList(),
        propertyKeys,
        propertyValues,
        propertyPairs);
  }

  static String encodeValues(Collection<String> values) {
    return normalizedValues(values).stream()
        .map(WorkflowSemanticProjectionValues::encodeValue)
        .collect(Collectors.joining("\n"));
  }

  static List<String> decodeValues(String encodedValues) {
    if (encodedValues == null || encodedValues.isBlank()) {
      return List.of();
    }
    return encodedValues
        .lines()
        .filter(value -> !value.isBlank())
        .map(WorkflowSemanticProjectionValues::decodeValue)
        .toList();
  }

  static String encodePair(String key, String value) {
    return encodeValue(requireNotBlank(key, "propertyKey"))
        + "."
        + encodeValue(Objects.requireNonNull(value, "propertyValue"));
  }

  static List<WorkflowSemanticProperty> decodePairs(Collection<String> encodedPairs) {
    Objects.requireNonNull(encodedPairs, "encodedPairs");
    return encodedPairs.stream()
        .map(WorkflowSemanticProjectionValues::decodePair)
        .sorted(
            Comparator.comparing(WorkflowSemanticProperty::key)
                .thenComparing(WorkflowSemanticProperty::value))
        .toList();
  }

  private static WorkflowSemanticProperty decodePair(String encodedPair) {
    int separator = Objects.requireNonNull(encodedPair, "encodedPair").indexOf('.');
    if (separator <= 0 || separator == encodedPair.length() - 1) {
      throw new IllegalArgumentException("Invalid semantic property-pair encoding");
    }
    return new WorkflowSemanticProperty(
        decodeValue(encodedPair.substring(0, separator)),
        decodeValue(encodedPair.substring(separator + 1)));
  }

  private static void addMetadata(
      Map<String, String> entries, List<String> keys, List<String> values, List<String> pairs) {
    entries.entrySet().stream()
        .sorted(Map.Entry.comparingByKey())
        .forEach(
            entry -> {
              String key = requireNotBlank(entry.getKey(), "propertyKey");
              String value = Objects.requireNonNull(entry.getValue(), "propertyValue");
              keys.add(key);
              values.add(value);
              pairs.add(encodePair(key, value));
            });
  }

  private static List<String> normalizedValues(Collection<String> values) {
    Objects.requireNonNull(values, "values");
    Set<String> normalized = new LinkedHashSet<>();
    values.stream()
        .map(value -> Objects.requireNonNull(value, "value"))
        .sorted()
        .forEach(normalized::add);
    return List.copyOf(normalized);
  }

  private static String encodeValue(String value) {
    return ENCODING_PREFIX + ENCODER.encodeToString(value.getBytes(StandardCharsets.UTF_8));
  }

  private static String decodeValue(String encodedValue) {
    if (!encodedValue.startsWith(ENCODING_PREFIX)) {
      throw new IllegalArgumentException("Unsupported semantic projection value encoding");
    }
    return new String(
        DECODER.decode(encodedValue.substring(ENCODING_PREFIX.length())), StandardCharsets.UTF_8);
  }

  private static String requireNotBlank(String value, String name) {
    Objects.requireNonNull(value, name);
    String normalized = value.trim();
    if (normalized.isEmpty()) {
      throw new IllegalArgumentException(name + " must not be blank");
    }
    return normalized;
  }
}