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

import cloud.nstart.terravault.ordinis.domain.definition.DictionaryDefinition;
import cloud.nstart.terravault.ordinis.domain.record.DictionaryRecord;
import cloud.nstart.terravault.ordinis.domain.record.DictionaryRecordRepository;
import cloud.nstart.terravault.ordinis.events.EventEnvelope;
import cloud.nstart.terravault.ordinis.events.record.DictionaryRecordCreated;
import cloud.nstart.terravault.ordinis.events.record.DictionaryRecordDeleted;
import cloud.nstart.terravault.ordinis.events.record.DictionaryRecordUpdated;
import cloud.nstart.terravault.ordinis.outbox.OutboxRecorder;
import cloud.nstart.terravault.ordinis.restapi.audit.AuditLogger;
import cloud.nstart.terravault.ordinis.restapi.dto.CreateRecordRequest;
import cloud.nstart.terravault.ordinis.restapi.error.OrdinisException;
import cloud.nstart.terravault.ordinis.validation.RecordValidator;
import cloud.nstart.terravault.ordinis.validation.ValidationException;
import cloud.nstart.terravault.ordinis.validation.ValidationResult;
import org.locationtech.jts.geom.Geometry;
import org.locationtech.jts.io.ParseException;
import org.locationtech.jts.io.WKTReader;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Isolation;
import org.springframework.transaction.annotation.Transactional;

import java.time.OffsetDateTime;
import java.util.List;
import java.util.Optional;
import java.util.UUID;

/**
 * Bitemporal write-side для записей. Все изменения через SERIALIZABLE
 * транзакцию (FK enforcement + EXCLUSION CONSTRAINT защищают от race).
 *
 * <p>Update = "close current version" + "create new version" — никогда не
 * UPDATE существующей строки in-place. Это обеспечивает Envers history +
 * проверяемость в любой момент времени.
 *
 * <p>Outbox запись — в той же транзакции что и change. Гарантирует at-least-once.
 */
@Service
public class DictionaryRecordService {

  private final DictionaryRecordRepository repository;
  private final DictionaryDefinitionService definitionService;
  private final RecordValidator validator;
  private final cloud.nstart.terravault.ordinis.restapi.service.reference.ReferenceValidator referenceValidator;
  private final cloud.nstart.terravault.ordinis.auth.ScopeContext scopeContext;
  private final OutboxRecorder outbox;
  private final AuditLogger auditLogger;
  /** Phase 3 dict-relationships-v2: pre-close проверка cascade plan. Optional —
   *  тесты с null deps инжектят null. */
  private final java.util.Optional<cloud.nstart.terravault.ordinis.restapi.service.reference.CascadeCloseService> cascadeService;

  private static final OffsetDateTime FAR_FUTURE = OffsetDateTime.parse("9999-12-31T23:59:59Z");

  @org.springframework.beans.factory.annotation.Autowired
  public DictionaryRecordService(
      DictionaryRecordRepository repository,
      DictionaryDefinitionService definitionService,
      RecordValidator validator,
      cloud.nstart.terravault.ordinis.restapi.service.reference.ReferenceValidator referenceValidator,
      cloud.nstart.terravault.ordinis.auth.ScopeContext scopeContext,
      OutboxRecorder outbox,
      AuditLogger auditLogger,
      java.util.Optional<cloud.nstart.terravault.ordinis.restapi.service.reference.CascadeCloseService> cascadeService) {
    this.repository = repository;
    this.definitionService = definitionService;
    this.validator = validator;
    this.referenceValidator = referenceValidator;
    this.scopeContext = scopeContext;
    this.outbox = outbox;
    this.auditLogger = auditLogger;
    this.cascadeService = cascadeService;
  }

  /** Backward-compat ctor для тестов и subclassing (BulkRecordServiceTest). */
  public DictionaryRecordService(
      DictionaryRecordRepository repository,
      DictionaryDefinitionService definitionService,
      RecordValidator validator,
      cloud.nstart.terravault.ordinis.restapi.service.reference.ReferenceValidator referenceValidator,
      cloud.nstart.terravault.ordinis.auth.ScopeContext scopeContext,
      OutboxRecorder outbox,
      AuditLogger auditLogger) {
    this(repository, definitionService, validator, referenceValidator,
        scopeContext, outbox, auditLogger, java.util.Optional.empty());
  }

  @Transactional(readOnly = true)
  public Optional<DictionaryRecord> findActive(String dictionaryName, String businessKey, OffsetDateTime at) {
    var d = definitionService.findByName(dictionaryName);
    return repository.findActiveAt(d.getId(), businessKey, at);
  }

  @Transactional(readOnly = true)
  public List<DictionaryRecord> findHistory(String dictionaryName, String businessKey) {
    var d = definitionService.findByName(dictionaryName);
    return repository.findByDictionaryIdAndBusinessKeyOrderByValidFromDesc(d.getId(), businessKey);
  }

  @Transactional(isolation = Isolation.SERIALIZABLE)
  public DictionaryRecord create(String dictionaryName, CreateRecordRequest req) {
    var d = definitionService.findByName(dictionaryName);
    requireApprovalGate(d, "create");
    validate(d, req);

    OffsetDateTime validFrom = req.validFrom() == null ? OffsetDateTime.now() : req.validFrom();
    OffsetDateTime validTo = req.validTo() == null ? FAR_FUTURE : req.validTo();

    String businessKey = resolveBusinessKey(d, req);

    if (repository.findActiveAt(d.getId(), businessKey, validFrom).isPresent()) {
      throw OrdinisException.conflict(
          "record_already_active",
          "Запись с business_key=" + businessKey + " уже активна на " + validFrom +
              ". Используйте PUT (update) для новой версии.");
    }

    enforceUniqueFields(d, req.data(), validFrom, null);

    var record = new DictionaryRecord(
        UUID.randomUUID(),
        d.getId(),
        businessKey,
        req.data(),
        d.getScope(),
        validFrom);
    record.setValidTo(validTo);
    record.setGeometry(resolveGeometry(req));

    DictionaryRecord saved;
    try {
      saved = repository.saveAndFlush(record);
    } catch (DataIntegrityViolationException ex) {
      if (isExclusionViolation(ex)) {
        throw OrdinisException.conflict(
            "record_period_overlap",
            "Период [" + validFrom + ", " + validTo + ") пересекается с существующей версией business_key='"
                + businessKey + "'. Закройте старую версию или скорректируйте даты.");
      }
      throw ex;
    }
    auditLogger.recordCreate(d, saved);
    publishCreated(d, saved);
    return saved;
  }

  @Transactional(isolation = Isolation.SERIALIZABLE)
  public DictionaryRecord update(String dictionaryName, String businessKey, CreateRecordRequest req) {
    var d = definitionService.findByName(dictionaryName);
    requireApprovalGate(d, "update");
    validate(d, req);

    OffsetDateTime newValidFrom = req.validFrom() == null ? OffsetDateTime.now() : req.validFrom();
    OffsetDateTime newValidTo = req.validTo() == null ? FAR_FUTURE : req.validTo();

    var current = repository.findActiveAt(d.getId(), businessKey, newValidFrom)
        .orElseThrow(() -> OrdinisException.notFound(
            "record_not_active",
            "Нет активной версии business_key='" + businessKey + "' на момент " + newValidFrom
                + ". Edit создаёт новую версию начиная с момента 'действует с' — выберите дату/время "
                + "когда уже существует активная запись (обычно — текущий момент или позже)."));

    var prevData = current.getData();
    UUID previousRecordId = current.getId();

    enforceUniqueFields(d, req.data(), newValidFrom, current.getId());

    current.setValidTo(newValidFrom);
    // saveAndFlush обязателен: иначе Hibernate отложит UPDATE до commit, INSERT новой
    // записи пойдёт первым → exclusion constraint dictionary_records_no_overlap бьёт,
    // т.к. старая ещё имеет valid_to=FAR_FUTURE и tstzrange'и пересекаются.
    repository.saveAndFlush(current);

    var next = new DictionaryRecord(
        UUID.randomUUID(),
        d.getId(),
        businessKey,
        req.data(),
        d.getScope(),
        newValidFrom);
    next.setValidTo(newValidTo);
    next.setGeometry(parseWkt(req.geometryWkt()));
    DictionaryRecord saved;
    try {
      saved = repository.saveAndFlush(next);
    } catch (DataIntegrityViolationException ex) {
      if (isExclusionViolation(ex)) {
        throw OrdinisException.conflict(
            "record_period_overlap",
            "Новый период [" + newValidFrom + ", " + newValidTo
                + ") пересекается с другой исторической версией business_key='"
                + businessKey + "'. Скорректируйте даты.");
      }
      throw ex;
    }

    auditLogger.recordUpdate(d, saved, prevData);
    publishUpdated(d, saved, previousRecordId, prevData);
    return saved;
  }

  @Transactional(isolation = Isolation.SERIALIZABLE)
  public void close(String dictionaryName, String businessKey, OffsetDateTime closeAt, String reason) {
    var d = definitionService.findByName(dictionaryName);
    requireApprovalGate(d, "close");
    OffsetDateTime when = closeAt == null ? OffsetDateTime.now() : closeAt;

    // Phase 3 dict-relationships-v2: если есть active dependents — close
    // блокируется (BLOCK или требует cascade-confirm для CASCADE). WARN
    // dependents допускают close, но возвращают warnings (current
    // contract: void return — warnings уйдут только в outbox + audit).
    cascadeService.ifPresent(svc -> {
      var plan = svc.evaluatePlan(dictionaryName, businessKey, when);
      if (!plan.blockers().isEmpty()) {
        throw OrdinisException.conflict(
            "x_references_blocked_by_dependents",
            "Cannot close " + dictionaryName + "/" + businessKey + " — "
                + plan.blockers().size()
                + " active dependents block close. Resolve them first.");
      }
      if (!plan.cascade().isEmpty()) {
        throw OrdinisException.conflict(
            "x_references_cascade_required",
            plan.cascade().size() + " dependents will be cascaded. "
                + "Use POST /records/{dict}/{key}/cascade-close with confirmed=true to proceed.");
      }
      // WARN-only — продолжаем close (warnings emitted via outbox indirectly).
    });

    var current = repository.findActiveAt(d.getId(), businessKey, when)
        .orElseThrow(() -> OrdinisException.notFound(
            "record_not_active",
            "Нет активной записи business_key=" + businessKey + " на " + when));

    current.setValidTo(when);
    repository.save(current);

    auditLogger.recordClose(d, current, reason);
    publishDeleted(d, current, when, reason);
  }

  // — internals —

  /**
   * Распознаёт PG exclusion constraint violation в любом нестоящем месте цепочки причин.
   * SQLState 23P01 = exclusion_violation.
   */
  private static boolean isExclusionViolation(Throwable ex) {
    Throwable t = ex;
    while (t != null) {
      String msg = t.getMessage();
      if (msg != null && (msg.contains("23P01")
          || msg.contains("dictionary_records_no_overlap")
          || msg.contains("exclusion constraint"))) {
        return true;
      }
      t = t.getCause();
    }
    return false;
  }

  /**
   * Approval Workflow v2 (D2=A): если dict отмечен approval_required=true,
   * direct CRUD blocked. Caller получает 409 draft_required с pointer на
   * drafts API. Bundle import / DraftService.applyApprovedDraft бypass'ят
   * через {@link #createDirect}/{@link #updateDirect}/{@link #closeDirect}.
   */
  private static void requireApprovalGate(DictionaryDefinition d, String op) {
    if (d.isApprovalRequired()) {
      throw OrdinisException.conflict(
          "draft_required",
          "Справочник '" + d.getName() + "' требует maker-checker approval. "
              + "Используйте POST /api/v1/dictionaries/" + d.getName()
              + "/records/drafts для submit'а " + op + "-proposal.");
    }
  }

  /**
   * Apply already-approved draft. Called by DraftService.approve(); bypasses
   * approval gate (drafts уже прошли review). Schema validation повторяется
   * here on-approve — schema может measure'ся между submit и approve.
   *
   * <p>Возвращает result типа depends on operation:
   * <ul>
   *   <li>CREATE/UPDATE: новый/обновлённый {@link DictionaryRecord}</li>
   *   <li>CLOSE: null (close — void operation)</li>
   * </ul>
   *
   * <p>Этот метод НЕ публичный — exposed через package-private для DraftService
   * только. Если в будущем потребуется внешний caller — добавить @Transactional
   * propagation requirements.
   */
  @Transactional(isolation = Isolation.SERIALIZABLE)
  public DictionaryRecord applyApprovedCreate(
      String dictionaryName, CreateRecordRequest req) {
    return createDirect(dictionaryName, req);
  }

  @Transactional(isolation = Isolation.SERIALIZABLE)
  public DictionaryRecord applyApprovedUpdate(
      String dictionaryName, String businessKey, CreateRecordRequest req) {
    return updateDirect(dictionaryName, businessKey, req);
  }

  @Transactional(isolation = Isolation.SERIALIZABLE)
  public void applyApprovedClose(
      String dictionaryName, String businessKey, OffsetDateTime closeAt, String reason) {
    closeDirect(dictionaryName, businessKey, closeAt, reason);
  }

  /** Internal CREATE — bypass approval gate. Same logic as create(). */
  private DictionaryRecord createDirect(String dictionaryName, CreateRecordRequest req) {
    var d = definitionService.findByName(dictionaryName);
    validate(d, req);

    OffsetDateTime validFrom = req.validFrom() == null ? OffsetDateTime.now() : req.validFrom();
    OffsetDateTime validTo = req.validTo() == null ? FAR_FUTURE : req.validTo();
    String businessKey = resolveBusinessKey(d, req);

    if (repository.findActiveAt(d.getId(), businessKey, validFrom).isPresent()) {
      throw OrdinisException.conflict(
          "record_already_active",
          "Запись с business_key=" + businessKey + " уже активна на " + validFrom +
              ". Используйте UPDATE для новой версии.");
    }
    enforceUniqueFields(d, req.data(), validFrom, null);

    var record = new DictionaryRecord(
        UUID.randomUUID(), d.getId(), businessKey, req.data(), d.getScope(), validFrom);
    record.setValidTo(validTo);
    record.setGeometry(resolveGeometry(req));

    DictionaryRecord saved;
    try {
      saved = repository.saveAndFlush(record);
    } catch (DataIntegrityViolationException ex) {
      if (isExclusionViolation(ex)) {
        throw OrdinisException.conflict(
            "record_period_overlap",
            "Период [" + validFrom + ", " + validTo + ") пересекается с существующей версией business_key='"
                + businessKey + "'. Закройте старую или скорректируйте даты.");
      }
      throw ex;
    }
    auditLogger.recordCreate(d, saved);
    publishCreated(d, saved);
    return saved;
  }

  /** Internal UPDATE — bypass approval gate. */
  private DictionaryRecord updateDirect(
      String dictionaryName, String businessKey, CreateRecordRequest req) {
    var d = definitionService.findByName(dictionaryName);
    validate(d, req);
    OffsetDateTime newValidFrom = req.validFrom() == null ? OffsetDateTime.now() : req.validFrom();
    OffsetDateTime newValidTo = req.validTo() == null ? FAR_FUTURE : req.validTo();

    var current = repository.findActiveAt(d.getId(), businessKey, newValidFrom)
        .orElseThrow(() -> OrdinisException.notFound(
            "record_not_active",
            "Нет активной версии business_key='" + businessKey + "' на " + newValidFrom));

    var prevData = current.getData();
    UUID previousRecordId = current.getId();
    enforceUniqueFields(d, req.data(), newValidFrom, current.getId());

    current.setValidTo(newValidFrom);
    repository.saveAndFlush(current);

    var next = new DictionaryRecord(
        UUID.randomUUID(), d.getId(), businessKey, req.data(), d.getScope(), newValidFrom);
    next.setValidTo(newValidTo);
    next.setGeometry(parseWkt(req.geometryWkt()));

    DictionaryRecord saved;
    try {
      saved = repository.saveAndFlush(next);
    } catch (DataIntegrityViolationException ex) {
      if (isExclusionViolation(ex)) {
        throw OrdinisException.conflict(
            "record_period_overlap",
            "Период [" + newValidFrom + ", " + newValidTo + ") пересекается с другой версией.");
      }
      throw ex;
    }
    auditLogger.recordUpdate(d, saved, prevData);
    publishUpdated(d, saved, previousRecordId, prevData);
    return saved;
  }

  /** Internal CLOSE — bypass approval gate. */
  private void closeDirect(
      String dictionaryName, String businessKey, OffsetDateTime closeAt, String reason) {
    var d = definitionService.findByName(dictionaryName);
    OffsetDateTime when = closeAt == null ? OffsetDateTime.now() : closeAt;
    var current = repository.findActiveAt(d.getId(), businessKey, when)
        .orElseThrow(() -> OrdinisException.notFound(
            "record_not_active",
            "Нет активной записи business_key=" + businessKey + " на " + when));
    current.setValidTo(when);
    repository.save(current);
    auditLogger.recordClose(d, current, reason);
    publishDeleted(d, current, when, reason);
  }

  private void validate(DictionaryDefinition d, CreateRecordRequest req) {
    ValidationResult vr = validator.validate(d, req.data());
    if (!vr.valid()) throw new ValidationException(vr.errors());

    // Cross-dictionary x-references — после schema validation, потому что
    // если schema fail, value может быть not-textual и refs check бросит false-positives.
    var refErrors = referenceValidator.validate(
        d.getSchemaJson(),
        req.data(),
        scopeContext.currentScopes().stream().toList(),
        OffsetDateTime.now());
    if (!refErrors.isEmpty()) throw new ValidationException(refErrors);
  }

  /**
   * Если в schema задано {@code x-id-source: <field>}, бизнес-ключ автоматически
   * берётся из {@code data[field]}. Если клиент явно прислал businessKey — он
   * должен совпадать, иначе 422 (защита от рассинхронизации).
   */
  String resolveBusinessKey(DictionaryDefinition d, CreateRecordRequest req) {
    var schema = d.getSchemaJson();
    var idSourceNode = schema == null ? null : schema.get("x-id-source");
    if (idSourceNode == null || !idSourceNode.isTextual() || idSourceNode.asText().isBlank()) {
      if (req.businessKey() == null || req.businessKey().isBlank()) {
        throw OrdinisException.badRequest(
            "business_key_required",
            "businessKey обязателен (в schema нет x-id-source).");
      }
      return req.businessKey();
    }
    String fieldName = idSourceNode.asText();
    var valueNode = req.data() == null ? null : req.data().get(fieldName);
    if (valueNode == null || valueNode.isNull()) {
      throw OrdinisException.badRequest(
          "id_source_missing",
          "Поле x-id-source='" + fieldName + "' пустое в data — бизнес-ключ невозможно вывести.");
    }
    String derived = valueNode.isTextual() ? valueNode.asText() : valueNode.toString();
    if (req.businessKey() != null && !req.businessKey().isBlank()
        && !req.businessKey().equals(derived)) {
      throw OrdinisException.badRequest(
          "business_key_mismatch",
          "businessKey='" + req.businessKey() + "' не совпадает с x-id-source='"
              + fieldName + "' value='" + derived + "'.");
    }
    return derived;
  }

  /**
   * Для каждого property с {@code x-unique: true} ищет другие активные записи
   * (на момент {@code at}) с тем же значением. Исключает текущую версию (для
   * update). Бьёт 409 при дубликате.
   */
  void enforceUniqueFields(
      DictionaryDefinition d,
      com.fasterxml.jackson.databind.JsonNode data,
      OffsetDateTime at,
      UUID excludeRecordId) {
    if (data == null) return;
    var schema = d.getSchemaJson();
    if (schema == null) return;
    var properties = schema.get("properties");
    if (properties == null || !properties.isObject()) return;

    var fieldNames = properties.fieldNames();
    while (fieldNames.hasNext()) {
      String fieldName = fieldNames.next();
      var fieldSchema = properties.get(fieldName);
      var unique = fieldSchema == null ? null : fieldSchema.get("x-unique");
      if (unique == null || !unique.asBoolean(false)) continue;

      var valueNode = data.get(fieldName);
      if (valueNode == null || valueNode.isNull()) continue;
      String value = valueNode.isTextual() ? valueNode.asText() : valueNode.toString();

      var conflicts = repository.findActiveByJsonField(d.getId(), at, fieldName, value);
      for (var c : conflicts) {
        if (excludeRecordId != null && c.getId().equals(excludeRecordId)) continue;
        throw OrdinisException.conflict(
            "unique_field_violation",
            "Поле '" + fieldName + "' помечено x-unique. Значение '" + value
                + "' уже используется в записи business_key='" + c.getBusinessKey() + "'.");
      }
    }
  }

  private Geometry parseWkt(String wkt) {
    if (wkt == null || wkt.isBlank()) return null;
    try {
      var reader = new WKTReader();
      Geometry g = reader.read(wkt);
      g.setSRID(4326);
      return g;
    } catch (ParseException e) {
      throw OrdinisException.badRequest("invalid_wkt", "Invalid WKT: " + e.getMessage());
    }
  }

  /**
   * Resolve final geometry для записи: explicit geometryWkt из request имеет
   * приоритет; если не задан — автодеривируется POINT из числовых полей
   * data.lat/lon (или latitude/longitude). Это позволяет AOI spatial filter
   * работать на справочниках типа ground_station/spacecraft без ручного
   * дублирования lat/lon → WKT в каждом record.
   *
   * <p>Validation: lat ∈ [-90, 90], lon ∈ [-180, 180]. Если значения вне
   * диапазона — игнорируем (не throw, чтобы не ломать legacy records со
   * странными данными). Спорные значения validate'ятся schema-уровнем
   * через {@code minimum/maximum}.
   */
  private Geometry resolveGeometry(CreateRecordRequest req) {
    if (req.geometryWkt() != null && !req.geometryWkt().isBlank()) {
      return parseWkt(req.geometryWkt());
    }
    var data = req.data();
    if (data == null) return null;
    Double lat = pickLatLon(data, "lat", "latitude");
    Double lon = pickLatLon(data, "lon", "longitude", "lng");
    if (lat == null || lon == null) return null;
    if (lat < -90.0 || lat > 90.0 || lon < -180.0 || lon > 180.0) return null;
    String pointWkt = "POINT(" + lon + " " + lat + ")";
    try {
      Geometry g = new WKTReader().read(pointWkt);
      g.setSRID(4326);
      return g;
    } catch (ParseException e) {
      // Не должно случиться (мы сами sсонструировали WKT) — но лучше null
      // чем 500 если cosmic ray flip'нет double.
      return null;
    }
  }

  private static Double pickLatLon(com.fasterxml.jackson.databind.JsonNode data, String... keys) {
    for (String key : keys) {
      var node = data.get(key);
      if (node != null && node.isNumber()) return node.doubleValue();
    }
    return null;
  }

  private void publishCreated(DictionaryDefinition d, DictionaryRecord r) {
    String wkt = r.getGeometry() == null ? null : new org.locationtech.jts.io.WKTWriter().write(r.getGeometry());
    var payload = new DictionaryRecordCreated(
        r.getId(), r.getBusinessKey(), r.getData(), wkt,
        r.getValidFrom(), r.getValidTo(), r.getCreatedAt(), r.getCreatedBy());
    var envelope = EventEnvelope.of(d.getName(), toEventScope(d.getScope()), d.getBundle(), payload);
    outbox.record(
        "RecordCreated", "DictionaryRecord", r.getId().toString(),
        d.getName(), d.getScope(), d.getBundle(),
        d.getName() + ":" + r.getBusinessKey(), envelope);
  }

  private void publishUpdated(
      DictionaryDefinition d, DictionaryRecord r,
      UUID prevId, com.fasterxml.jackson.databind.JsonNode prevData) {
    String wkt = r.getGeometry() == null ? null : new org.locationtech.jts.io.WKTWriter().write(r.getGeometry());
    var payload = new DictionaryRecordUpdated(
        r.getId(), prevId, r.getBusinessKey(), r.getData(), prevData, wkt,
        r.getValidFrom(), r.getValidTo(), r.getUpdatedAt(), r.getUpdatedBy());
    var envelope = EventEnvelope.of(d.getName(), toEventScope(d.getScope()), d.getBundle(), payload);
    outbox.record(
        "RecordUpdated", "DictionaryRecord", r.getId().toString(),
        d.getName(), d.getScope(), d.getBundle(),
        d.getName() + ":" + r.getBusinessKey(), envelope);
  }

  private void publishDeleted(DictionaryDefinition d, DictionaryRecord r, OffsetDateTime closedAt, String reason) {
    var payload = new DictionaryRecordDeleted(
        r.getId(), r.getBusinessKey(), closedAt, OffsetDateTime.now(), r.getUpdatedBy(), reason);
    var envelope = EventEnvelope.of(d.getName(), toEventScope(d.getScope()), d.getBundle(), payload);
    outbox.record(
        "RecordDeleted", "DictionaryRecord", r.getId().toString(),
        d.getName(), d.getScope(), d.getBundle(),
        d.getName() + ":" + r.getBusinessKey(), envelope);
  }

  private static cloud.nstart.terravault.ordinis.events.DataScope toEventScope(
      cloud.nstart.terravault.ordinis.domain.DataScope d) {
    return cloud.nstart.terravault.ordinis.events.DataScope.valueOf(d.name());
  }
}
