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

import cloud.nstart.terravault.ordinis.domain.definition.DictionaryDefinition;
import cloud.nstart.terravault.ordinis.domain.definition.DictionaryDefinitionRepository;
import cloud.nstart.terravault.ordinis.events.EventEnvelope;
import cloud.nstart.terravault.ordinis.events.definition.DictionaryDefinitionCreated;
import cloud.nstart.terravault.ordinis.events.definition.DictionaryDefinitionUpdated;
import cloud.nstart.terravault.ordinis.outbox.OutboxRecorder;
import cloud.nstart.terravault.ordinis.restapi.audit.AuditLogger;
import cloud.nstart.terravault.ordinis.restapi.dto.CreateDictionaryRequest;
import cloud.nstart.terravault.ordinis.restapi.error.OrdinisException;
import cloud.nstart.terravault.ordinis.restapi.service.schema.SchemaCompatibility;
import com.fasterxml.jackson.databind.JsonNode;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import java.util.List;
import java.util.UUID;

@Service
public class DictionaryDefinitionService {

  private final DictionaryDefinitionRepository repository;
  private final OutboxRecorder outboxRecorder;
  /** Optional — None в tests/legacy конструкторе (без audit). */
  private final AuditLogger auditLogger;

  @Autowired
  public DictionaryDefinitionService(
      DictionaryDefinitionRepository repository,
      OutboxRecorder outboxRecorder,
      AuditLogger auditLogger) {
    this.repository = repository;
    this.outboxRecorder = outboxRecorder;
    this.auditLogger = auditLogger;
  }

  /** Test-friendly ctor без AuditLogger (existing tests без audit dep). */
  public DictionaryDefinitionService(
      DictionaryDefinitionRepository repository, OutboxRecorder outboxRecorder) {
    this(repository, outboxRecorder, null);
  }

  @Transactional(readOnly = true)
  public List<DictionaryDefinition> findAll() {
    return repository.findAll();
  }

  @Transactional(readOnly = true)
  public DictionaryDefinition findByName(String name) {
    return repository.findByName(name)
        .orElseThrow(() -> OrdinisException.notFound(
            "dictionary_not_found", "Dictionary not found: " + name));
  }

  @Transactional(readOnly = true)
  public DictionaryDefinition findById(UUID id) {
    return repository.findById(id)
        .orElseThrow(() -> OrdinisException.notFound(
            "dictionary_not_found", "Dictionary not found by id: " + id));
  }

  @Transactional
  public DictionaryDefinition create(CreateDictionaryRequest req) {
    if (repository.existsByName(req.name())) {
      throw OrdinisException.conflict(
          "dictionary_already_exists", "Dictionary already exists: " + req.name());
    }

    var d = new DictionaryDefinition(UUID.randomUUID(), req.name(), req.scope(), req.schemaJson());
    d.setDisplayName(req.displayName());
    d.setDescription(req.description());
    if (req.schemaVersion() != null) d.setSchemaVersion(req.schemaVersion());
    if (req.bundle() != null) d.setBundle(req.bundle());
    if (req.supportedLocales() != null && !req.supportedLocales().isEmpty()) {
      d.setSupportedLocales(req.supportedLocales().toArray(String[]::new));
    }
    if (req.defaultLocale() != null) d.setDefaultLocale(req.defaultLocale());
    if (req.redisProjectionEnabled() != null) {
      d.setRedisProjectionEnabled(req.redisProjectionEnabled());
    }
    if (req.approvalRequired() != null) {
      d.setApprovalRequired(req.approvalRequired());
    }
    if (req.approvalMinRole() != null) {
      d.setApprovalMinRole(req.approvalMinRole().isBlank() ? null : req.approvalMinRole());
    }

    var saved = repository.save(d);

    var event = new DictionaryDefinitionCreated(
        saved.getId(),
        saved.getName(),
        saved.getDisplayName(),
        saved.getDescription(),
        toEventScope(saved.getScope()),
        saved.getSchemaJson(),
        saved.getSchemaVersion(),
        saved.getBundle(),
        saved.getSupportedLocales() == null ? List.of() : List.of(saved.getSupportedLocales()),
        saved.getDefaultLocale(),
        saved.getCreatedAt(),
        saved.getCreatedBy());

    var envelope = EventEnvelope.of(saved.getName(), toEventScope(saved.getScope()), saved.getBundle(), event);

    outboxRecorder.record(
        "DefinitionCreated",
        "DictionaryDefinition",
        saved.getId().toString(),
        saved.getName(),
        saved.getScope(),
        saved.getBundle(),
        "definition:" + saved.getName(),
        envelope);

    if (auditLogger != null) {
      auditLogger.definitionCreated(saved);
    }

    return saved;
  }

  @Transactional
  public DictionaryDefinition updateSchema(String name, CreateDictionaryRequest req) {
    var existing = findByName(name);
    var prevSchema = existing.getSchemaJson();

    // Approval gate (revised 2026-05-14 after first-day-in-prod feedback):
    // approvalRequired dicts must route BREAKING schema changes + governance
    // toggles through the schema-draft workflow. Non-breaking edits — adding
    // an optional field, widening an enum, editing description / title —
    // pass inline (verified by SchemaCompatibility.isBreaking).
    //
    // Three clauses, all with the same remedy (open a draft):
    //   1. schemaBreaking          — add required field, type change, etc.
    //   2. tryingToDisableApproval — flip approvalRequired:true→false
    //                                 (rewrites the gate itself).
    //   3. tryingToChangeMinRole   — change/clear approvalMinRole; would let
    //                                 a maker self-approve restricted-dict
    //                                 drafts via a generic reviewer role.
    //
    // SchemaCompatibility.isBreaking is conservative — false positives are
    // recoverable (open a draft that turns out to be trivial) but false
    // negatives let real breaking changes slip past review.
    boolean schemaBreaking =
        SchemaCompatibility.isBreaking(prevSchema, req.schemaJson());
    boolean tryingToDisableApproval =
        req.approvalRequired() != null && !req.approvalRequired();
    // Approval-min-role flip is a separate backdoor: a maker with PUT
    // permission could clear approvalMinRole (send "" → stored as null) and
    // then approve their own restricted-dict drafts using a generic
    // RECORD_REVIEWER role. Treat any change to approvalMinRole the same as
    // a breaking schema change — route through the draft workflow.
    boolean tryingToChangeMinRole =
        req.approvalMinRole() != null
            && !java.util.Objects.equals(
                req.approvalMinRole().isBlank() ? null : req.approvalMinRole(),
                existing.getApprovalMinRole());
    if (existing.isApprovalRequired()
        && (schemaBreaking || tryingToDisableApproval || tryingToChangeMinRole)) {
      throw OrdinisException.unprocessableEntity(
          "approval_required",
          "Breaking schema changes, approvalRequired toggle-off, and "
              + "approvalMinRole edits require the draft workflow on this "
              + "dictionary. Use POST /api/v1/admin/dictionaries/" + name
              + "/schema-drafts. Non-breaking edits (add optional field, "
              + "widen enum, edit description) pass inline.");
    }

    existing.setDisplayName(req.displayName());
    existing.setDescription(req.description());
    existing.setSchemaJson(req.schemaJson());
    if (req.schemaVersion() != null) existing.setSchemaVersion(req.schemaVersion());
    if (req.supportedLocales() != null && !req.supportedLocales().isEmpty()) {
      existing.setSupportedLocales(req.supportedLocales().toArray(String[]::new));
    }
    if (req.defaultLocale() != null) existing.setDefaultLocale(req.defaultLocale());
    if (req.redisProjectionEnabled() != null) {
      existing.setRedisProjectionEnabled(req.redisProjectionEnabled());
    }
    if (req.approvalRequired() != null) {
      existing.setApprovalRequired(req.approvalRequired());
    }
    if (req.approvalMinRole() != null) {
      existing.setApprovalMinRole(
          req.approvalMinRole().isBlank() ? null : req.approvalMinRole());
    }

    var saved = repository.save(existing);

    var event = new DictionaryDefinitionUpdated(
        saved.getId(),
        saved.getName(),
        saved.getDisplayName(),
        saved.getDescription(),
        toEventScope(saved.getScope()),
        saved.getSchemaJson(),
        prevSchema,
        saved.getSchemaVersion(),
        saved.getBundle(),
        saved.getSupportedLocales() == null ? List.of() : List.of(saved.getSupportedLocales()),
        saved.getDefaultLocale(),
        saved.getUpdatedAt(),
        saved.getUpdatedBy());

    var envelope = EventEnvelope.of(saved.getName(), toEventScope(saved.getScope()), saved.getBundle(), event);

    outboxRecorder.record(
        "DefinitionUpdated",
        "DictionaryDefinition",
        saved.getId().toString(),
        saved.getName(),
        saved.getScope(),
        saved.getBundle(),
        "definition:" + saved.getName(),
        envelope);

    if (auditLogger != null) {
      auditLogger.definitionUpdated(saved, prevSchema);
    }

    return saved;
  }

  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());
  }
}
