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

import cloud.nstart.terravault.ordinis.auth.ScopeContext;
import cloud.nstart.terravault.ordinis.domain.definition.DictionaryDefinition;
import cloud.nstart.terravault.ordinis.domain.definition.DictionaryDefinitionRepository;
import cloud.nstart.terravault.ordinis.restapi.audit.AuditLogger;
import cloud.nstart.terravault.ordinis.restapi.dto.PatchSchemaRequest;
import cloud.nstart.terravault.ordinis.restapi.error.OrdinisException;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.node.ObjectNode;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import java.util.Iterator;
import java.util.Map;
import java.util.Set;

/**
 * Inline non-breaking schema patch — backend #4 из pending-endpoints brief.
 *
 * <p>Применяет узкий allow-list mutations к одному property без breaking
 * existing records. Bumps schema_version в patch component (1.4.0 → 1.4.1).
 * Cascades через DictionaryDefinitionService.updateSchema чтобы переиспользовать
 * outbox emit + audit logging.
 *
 * <p>Allowed change keys:
 * <ul>
 *   <li>{@code description} — JsonNode (localized object или plain string)</li>
 *   <li>{@code title} — localized object</li>
 *   <li>{@code minimum} / {@code maximum} — numeric (expansion only —
 *       narrowing rejected т.к. может invalidate existing records)</li>
 *   <li>{@code enum} — array. Mutation должна только ADD values, not remove
 *       (rejecting → 422 breaking_change_requires_draft)</li>
 * </ul>
 *
 * <p>Breaking change keys (type, format, required, x-references, *Of) →
 * 422 {@code breaking_change_requires_draft}.
 */
@Service
public class SchemaPatchService {

  private static final Logger log = LoggerFactory.getLogger(SchemaPatchService.class);

  private static final Set<String> ALLOWED_KEYS = Set.of(
      "description", "title", "minimum", "maximum", "enum");

  private final DictionaryDefinitionRepository repository;
  private final DictionaryDefinitionService definitionService;
  private final ScopeContext scopeContext;
  /** Optional — null в test ctor'е. */
  private final AuditLogger auditLogger;

  @Autowired
  public SchemaPatchService(
      DictionaryDefinitionRepository repository,
      DictionaryDefinitionService definitionService,
      ScopeContext scopeContext,
      AuditLogger auditLogger) {
    this.repository = repository;
    this.definitionService = definitionService;
    this.scopeContext = scopeContext;
    this.auditLogger = auditLogger;
  }

  public SchemaPatchService(
      DictionaryDefinitionRepository repository,
      DictionaryDefinitionService definitionService,
      ScopeContext scopeContext) {
    this(repository, definitionService, scopeContext, null);
  }

  /**
   * Apply patch. Throws OrdinisException на validation issues:
   * <ul>
   *   <li>404 {@code dictionary_not_found}</li>
   *   <li>404 {@code field_not_found} — field отсутствует в schema.properties</li>
   *   <li>409 {@code version_mismatch} — expectedHeadVersion stale</li>
   *   <li>422 {@code breaking_change_requires_draft} — patch trying breaking key</li>
   *   <li>422 {@code validation_failed} — numeric narrowing / enum removal</li>
   * </ul>
   */
  @Transactional
  public PatchSchemaResponse patchSchema(String dictName, PatchSchemaRequest req) {
    DictionaryDefinition d = definitionService.findByName(dictName);
    if (!scopeContext.canAccess(d.getScope())) {
      throw OrdinisException.notFound(
          "dictionary_not_found", "Dictionary not found: " + dictName);
    }
    // No approval gate here on purpose. PATCH /schema is restricted by
    // construction to non-breaking mutations: ALLOWED_KEYS = description /
    // title / minimum / maximum / enum, with validators that reject numeric
    // narrowing and enum removal (lines below). After the policy revision
    // 2026-05-14 — non-breaking edits pass inline even on approvalRequired
    // dicts — this endpoint is safe to leave open. PUT /{name} carries the
    // breaking-vs-non-breaking gate via SchemaCompatibility because it
    // accepts arbitrary schema rewrites.
    if (!d.getSchemaVersion().equals(req.expectedHeadVersion())) {
      throw OrdinisException.conflict(
          "version_mismatch",
          "expectedHeadVersion=" + req.expectedHeadVersion()
              + " but current is " + d.getSchemaVersion());
    }

    JsonNode schema = d.getSchemaJson();
    if (!(schema instanceof ObjectNode schemaObj)) {
      throw OrdinisException.badRequest(
          "invalid_schema", "Schema is not a JSON object");
    }
    JsonNode propertiesNode = schemaObj.get("properties");
    if (!(propertiesNode instanceof ObjectNode props)) {
      throw OrdinisException.notFound(
          "field_not_found", "Schema has no properties");
    }
    JsonNode targetProp = props.get(req.field());
    if (!(targetProp instanceof ObjectNode targetObj)) {
      throw OrdinisException.notFound(
          "field_not_found", "Field not in schema.properties: " + req.field());
    }

    JsonNode changesNode = req.changes();
    if (!changesNode.isObject()) {
      throw OrdinisException.badRequest(
          "invalid_changes", "changes must be a JSON object");
    }

    // Validate каждый change key + apply на копию target prop.
    ObjectNode patched = targetObj.deepCopy();
    Iterator<Map.Entry<String, JsonNode>> it = changesNode.fields();
    while (it.hasNext()) {
      Map.Entry<String, JsonNode> entry = it.next();
      String key = entry.getKey();
      JsonNode newValue = entry.getValue();
      if (!ALLOWED_KEYS.contains(key)) {
        throw OrdinisException.unprocessableEntity(
            "breaking_change_requires_draft",
            "Key '" + key + "' requires workflow draft, not inline patch."
                + " Allowed inline keys: " + ALLOWED_KEYS);
      }
      JsonNode oldValue = targetObj.get(key);
      validateChange(key, oldValue, newValue);
      patched.set(key, newValue);
    }

    // Apply patched property в новый schemaJson + bump version.
    ObjectNode newProps = props.deepCopy();
    newProps.set(req.field(), patched);
    ObjectNode newSchema = schemaObj.deepCopy();
    newSchema.set("properties", newProps);

    String oldVersion = d.getSchemaVersion();
    String newVersion = bumpPatchVersion(oldVersion);

    JsonNode prevSchema = d.getSchemaJson();
    d.setSchemaJson(newSchema);
    d.setSchemaVersion(newVersion);
    DictionaryDefinition saved = repository.save(d);

    if (auditLogger != null) {
      auditLogger.definitionUpdated(saved, prevSchema);
    }
    log.info("Schema patched: dict={} field={} oldVersion={} newVersion={}",
        dictName, req.field(), oldVersion, newVersion);

    return new PatchSchemaResponse(newVersion, saved.getSchemaJson());
  }

  /** Per-key non-breaking validation. */
  private static void validateChange(String key, JsonNode oldValue, JsonNode newValue) {
    switch (key) {
      case "description":
      case "title":
        // Free-form text/object — всегда non-breaking.
        return;
      case "minimum": {
        if (oldValue != null && oldValue.isNumber() && newValue.isNumber()) {
          double oldNum = oldValue.doubleValue();
          double newNum = newValue.doubleValue();
          if (newNum > oldNum) {
            throw OrdinisException.unprocessableEntity(
                "validation_failed",
                "minimum tighter (" + newNum + " > " + oldNum
                    + ") могло бы invalidate existing records. Use draft.");
          }
        }
        return;
      }
      case "maximum": {
        if (oldValue != null && oldValue.isNumber() && newValue.isNumber()) {
          double oldNum = oldValue.doubleValue();
          double newNum = newValue.doubleValue();
          if (newNum < oldNum) {
            throw OrdinisException.unprocessableEntity(
                "validation_failed",
                "maximum tighter (" + newNum + " < " + oldNum
                    + ") могло бы invalidate existing records. Use draft.");
          }
        }
        return;
      }
      case "enum": {
        if (oldValue != null && oldValue.isArray() && newValue.isArray()) {
          // new enum должен содержать все старые values (only-additions).
          java.util.Set<String> oldSet = new java.util.HashSet<>();
          oldValue.forEach(v -> oldSet.add(v.asText()));
          java.util.Set<String> newSet = new java.util.HashSet<>();
          newValue.forEach(v -> newSet.add(v.asText()));
          if (!newSet.containsAll(oldSet)) {
            oldSet.removeAll(newSet);
            throw OrdinisException.unprocessableEntity(
                "validation_failed",
                "enum removes existing values: " + oldSet + ". Use draft.");
          }
        }
        return;
      }
      default:
        // Shouldn't happen — ALLOWED_KEYS check выше.
        throw OrdinisException.unprocessableEntity(
            "breaking_change_requires_draft", "Key not allowed: " + key);
    }
  }

  /**
   * Bump semver patch component (1.4.0 → 1.4.1). Non-semver versions
   * → append ".1" / "+1". Best-effort, не throws.
   */
  static String bumpPatchVersion(String version) {
    if (version == null || version.isBlank()) return "1.0.1";
    String[] parts = version.split("\\.");
    if (parts.length != 3) return version + ".1";
    try {
      int patch = Integer.parseInt(parts[2]);
      return parts[0] + "." + parts[1] + "." + (patch + 1);
    } catch (NumberFormatException e) {
      return version + ".1";
    }
  }

  // === DTOs ===

  public record PatchSchemaResponse(String newVersion, JsonNode schemaJson) {}
}
