LocalizationExperimentCodec.java

package org.hammer.audio.acquisition;

import java.io.IOException;
import java.io.StringReader;
import java.net.URLDecoder;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.time.Instant;
import java.util.Base64;
import java.util.Map;
import java.util.Properties;
import java.util.concurrent.ConcurrentHashMap;
import java.util.stream.Collectors;

/** Deterministic text codec for complete localization experiment manifests. */
public final class LocalizationExperimentCodec {

  private static final String SCHEMA_VERSION = "1";
  private static final String METADATA_PREFIX = "metadata.";

  private final MicrophoneArrayProfileCodec profileCodec;

  /** Create a codec using the canonical profile codec. */
  public LocalizationExperimentCodec() {
    this(new MicrophoneArrayProfileCodec());
  }

  /** Create a codec with an explicit profile codec. */
  public LocalizationExperimentCodec(MicrophoneArrayProfileCodec profileCodec) {
    this.profileCodec = java.util.Objects.requireNonNull(profileCodec, "profileCodec");
  }

  /** Encode one complete experiment manifest in stable key order. */
  public String encode(LocalizationExperiment experiment) {
    Map<String, String> values = new ConcurrentHashMap<>();
    put(values, "schema", SCHEMA_VERSION);
    put(values, "experiment.id", experiment.experimentId());
    put(values, "experiment.name", experiment.displayName());
    put(values, "experiment.mode", experiment.inputMode().name());
    put(values, "experiment.source", experiment.sourceReference());
    put(values, "experiment.createdAt", experiment.createdAt().toString());
    put(values, "experiment.stage", experiment.stage().name());
    String profile =
        Base64.getUrlEncoder()
            .withoutPadding()
            .encodeToString(
                profileCodec.encode(experiment.profile()).getBytes(StandardCharsets.UTF_8));
    put(values, "experiment.profile", profile);
    put(values, METADATA_PREFIX + "count", Integer.toString(experiment.metadata().size()));
    int index = 0;
    for (Map.Entry<String, String> entry : experiment.metadata().entrySet()) {
      put(values, METADATA_PREFIX + index + ".key", entry.getKey());
      put(values, METADATA_PREFIX + index + ".value", entry.getValue());
      index++;
    }
    return values.entrySet().stream()
        .sorted(Map.Entry.comparingByKey())
        .map(entry -> entry.getKey() + '=' + entry.getValue())
        .collect(Collectors.joining("\n", "", "\n"));
  }

  /** Decode one complete experiment manifest. */
  public LocalizationExperiment decode(String encoded) {
    if (encoded == null || encoded.isBlank()) {
      throw new IllegalArgumentException("encoded experiment must not be blank");
    }
    Properties properties = new Properties();
    try {
      properties.load(new StringReader(encoded));
    } catch (IOException exception) {
      throw new IllegalArgumentException("experiment text could not be read", exception);
    }
    requireValue(properties, "schema", SCHEMA_VERSION);
    byte[] profileBytes = Base64.getUrlDecoder().decode(value(properties, "experiment.profile"));
    MicrophoneArrayProfile profile =
        profileCodec.decode(new String(profileBytes, StandardCharsets.UTF_8));
    int metadataCount = Integer.parseInt(value(properties, METADATA_PREFIX + "count"));
    Map<String, String> metadata = new ConcurrentHashMap<>();
    for (int index = 0; index < metadataCount; index++) {
      metadata.put(
          value(properties, METADATA_PREFIX + index + ".key"),
          value(properties, METADATA_PREFIX + index + ".value"));
    }
    return new LocalizationExperiment(
        value(properties, "experiment.id"),
        value(properties, "experiment.name"),
        profile,
        LocalizationInputMode.valueOf(value(properties, "experiment.mode")),
        value(properties, "experiment.source"),
        Instant.parse(value(properties, "experiment.createdAt")),
        LocalizationExperimentStage.valueOf(value(properties, "experiment.stage")),
        metadata);
  }

  private static void put(Map<String, String> values, String key, String value) {
    values.put(key, URLEncoder.encode(value, StandardCharsets.UTF_8));
  }

  private static String value(Properties properties, String key) {
    String value = properties.getProperty(key);
    if (value == null) {
      throw new IllegalArgumentException("missing experiment property: " + key);
    }
    return URLDecoder.decode(value, StandardCharsets.UTF_8);
  }

  private static void requireValue(Properties properties, String key, String expected) {
    String actual = value(properties, key);
    if (!expected.equals(actual)) {
      throw new IllegalArgumentException(
          "unsupported " + key + ": expected " + expected + " but was " + actual);
    }
  }
}