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

import cloud.nstart.terravault.ordinis.auth.ScopeContext;
import cloud.nstart.terravault.ordinis.domain.audit.AuditLog;
import cloud.nstart.terravault.ordinis.domain.audit.AuditLogRepository;
import cloud.nstart.terravault.ordinis.domain.definition.DictionaryDefinition;
import cloud.nstart.terravault.ordinis.restapi.error.OrdinisException;
import com.fasterxml.jackson.databind.JsonNode;
import org.springframework.data.domain.PageRequest;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import java.time.OffsetDateTime;
import java.util.ArrayList;
import java.util.Iterator;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.TreeSet;

/**
 * Builds schema changelog timeline для admin-UI History tab. Reads audit_log
 * (DEFINITION_CREATED / DEFINITION_UPDATED events) и формирует entries с
 * version / publishedAt / publishedBy / diff summary.
 *
 * <p>«Version» tracking: каждая audit_log row не хранит schema_version
 * (на момент write мы знаем только текущий — он же финальный для этой row).
 * Сейчас фронту нужно показать sequence; semver value берётся только для
 * HEAD entry из {@link DictionaryDefinition#getSchemaVersion()}. Старые
 * entries показываются с null version + dates. Backfill semver-в-аудит
 * затея отдельной миграции (см. TODO inline).
 *
 * <p>Diff computation — best-effort key-set comparison:
 * <ul>
 *   <li>{@code added}: ключи которые есть в payload_after.properties но не в before</li>
 *   <li>{@code removed}: ключи в before но не в after</li>
 *   <li>{@code changed}: ключи в обоих, но JsonNode#equals == false</li>
 * </ul>
 *
 * <p>Frontend строит human-readable строки из этих списков ("+altitude /
 * ~operator"). Пустой diff (после=до) — мутация прошла но без property
 * изменений (description/metadata), показываем "—" в UI.
 */
@Service
public class ChangelogService {

  /**
   * Full timeline events — definition-level CRUD + schema workflow drafts.
   * Раньше включал только DEFINITION_* — пользователь жаловался «полный
   * timeline pending», т.к. workflow draft transitions (created / review /
   * decided / published / withdrawn) не появлялись в History tab.
   */
  private static final List<String> DEFINITION_EVENT_TYPES = List.of(
      "DEFINITION_CREATED",
      "DEFINITION_UPDATED",
      "SCHEMA_DRAFT_CREATED",
      "SCHEMA_DRAFT_UPDATED",
      "SCHEMA_DRAFT_REVIEW_REQUESTED",
      "SCHEMA_DRAFT_DECIDED",
      "SCHEMA_DRAFT_PUBLISHED",
      "SCHEMA_DRAFT_WITHDRAWN");

  /**
   * Event types которые меняют live schema → формируют version row в timeline.
   * Остальные (draft draft_created, review, decided=non-publish, withdrawn)
   * — promotional events без version bump'а.
   */
  private static final Set<String> VERSIONING_EVENTS = Set.of(
      "DEFINITION_CREATED",
      "DEFINITION_UPDATED",
      "SCHEMA_DRAFT_PUBLISHED");

  private final AuditLogRepository auditRepo;
  private final DictionaryDefinitionService definitionService;
  private final ScopeContext scopeContext;

  public ChangelogService(
      AuditLogRepository auditRepo,
      DictionaryDefinitionService definitionService,
      ScopeContext scopeContext) {
    this.auditRepo = auditRepo;
    this.definitionService = definitionService;
    this.scopeContext = scopeContext;
  }

  /**
   * Build changelog response для dict. throws 404 если dict не существует
   * либо caller не имеет к нему scope access.
   *
   * @param name dict name
   * @param limit max entries (cap 200)
   */
  @Transactional(readOnly = true)
  public ChangelogResponse findChangelog(String name, int limit) {
    DictionaryDefinition d = definitionService.findByName(name);
    if (!scopeContext.canAccess(d.getScope())) {
      throw OrdinisException.notFound(
          "dictionary_not_found", "Dictionary not found: " + name);
    }
    int safeLimit = Math.max(1, Math.min(limit, 200));
    List<AuditLog> rows = auditRepo.findByDictionaryIdAndEventTypes(
        d.getId(), DEFINITION_EVENT_TYPES, PageRequest.of(0, safeLimit));

    // Find the most recent versioning event — оно current HEAD.
    int mostRecentVersioningIdx = -1;
    for (int i = 0; i < rows.size(); i++) {
      if (VERSIONING_EVENTS.contains(rows.get(i).getEventType())) {
        mostRecentVersioningIdx = i;
        break;
      }
    }

    List<ChangelogEntry> entries = new ArrayList<>(rows.size());
    for (int i = 0; i < rows.size(); i++) {
      AuditLog row = rows.get(i);
      boolean isCurrent = i == mostRecentVersioningIdx;
      // Version label: только для versioning events; older entries без backfill = null.
      String version = isCurrent
          ? d.getSchemaVersion()
          : null;
      DiffSummary diff = computeDiff(row.getPayloadBefore(), row.getPayloadAfter());
      String kind = mapEventTypeToKind(row.getEventType(), row.getAction());
      entries.add(new ChangelogEntry(
          row.getId(),
          version,
          row.getEventTime(),
          new Publisher(row.getUserId(), row.getUserId()),
          null, // headers (commit msg) not captured yet — see TODO
          diff,
          0,    // recordsAffected — not tracked at audit level; future field
          isCurrent,
          kind));
    }

    return new ChangelogResponse(d.getName(), d.getSchemaVersion(), entries);
  }

  /**
   * Получить full diff (before + after schemas) для конкретной changelog
   * entry. Используется History tab "Сравнить с..." кнопкой и Time-travel
   * preview — фронту нужны полные schemas а не keys-only summary чтобы
   * показать deep diff (nested properties, enum values, x-references).
   *
   * <p>Throws 404 если entry не существует, не принадлежит этому dict'у,
   * либо caller не имеет scope access к dict'у.
   */
  @Transactional(readOnly = true)
  public ChangelogDiff findEntryDiff(String name, Long entryId) {
    DictionaryDefinition d = definitionService.findByName(name);
    if (!scopeContext.canAccess(d.getScope())) {
      throw OrdinisException.notFound(
          "dictionary_not_found", "Dictionary not found: " + name);
    }
    AuditLog row = auditRepo.findById(entryId).orElseThrow(() ->
        OrdinisException.notFound(
            "changelog_entry_not_found",
            "Changelog entry " + entryId + " не найдена."));
    if (row.getDictionaryId() == null || !row.getDictionaryId().equals(d.getId())) {
      // Принадлежит другому dict'у — притворяемся что не существует
      // чтобы не утечь existence через id enumeration.
      throw OrdinisException.notFound(
          "changelog_entry_not_found",
          "Changelog entry " + entryId + " не найдена.");
    }
    if (!DEFINITION_EVENT_TYPES.contains(row.getEventType())) {
      // Это record-level event'а или unrelated entry — diff endpoint
      // specifically для schema timeline.
      throw OrdinisException.notFound(
          "changelog_entry_not_found",
          "Entry " + entryId + " не является schema timeline event'ом.");
    }

    DiffSummary summary = computeDiff(row.getPayloadBefore(), row.getPayloadAfter());
    String kind = mapEventTypeToKind(row.getEventType(), row.getAction());

    return new ChangelogDiff(
        row.getId(),
        d.getName(),
        row.getEventType(),
        kind,
        row.getEventTime(),
        new Publisher(row.getUserId(), row.getUserId()),
        row.getPayloadBefore(),
        row.getPayloadAfter(),
        summary);
  }

  /**
   * Map raw event_type + action в frontend-friendly kind label. Используется
   * UI чтобы рендерить разные icons / colors / labels:
   *  - {@code definition_create} / {@code definition_update} — direct schema change
   *  - {@code draft_create} / {@code draft_review} — pre-publish phases
   *  - {@code draft_approve} / {@code draft_reject} / {@code draft_changes_requested}
   *  - {@code draft_publish} — version-bumping publication
   *  - {@code draft_withdraw} — abandoned draft
   *  - {@code other} — fallback для unknown
   */
  private static String mapEventTypeToKind(String eventType, String action) {
    if (eventType == null) return "other";
    return switch (eventType) {
      case "DEFINITION_CREATED" -> "definition_create";
      case "DEFINITION_UPDATED" -> "definition_update";
      case "SCHEMA_DRAFT_CREATED" -> "draft_create";
      case "SCHEMA_DRAFT_UPDATED" -> "draft_update";
      case "SCHEMA_DRAFT_REVIEW_REQUESTED" -> "draft_review";
      case "SCHEMA_DRAFT_DECIDED" -> switch (action == null ? "" : action) {
        case "DRAFT_APPROVE" -> "draft_approve";
        case "DRAFT_REJECT" -> "draft_reject";
        case "DRAFT_CHANGES_REQUESTED" -> "draft_changes_requested";
        default -> "draft_decided";
      };
      case "SCHEMA_DRAFT_PUBLISHED" -> "draft_publish";
      case "SCHEMA_DRAFT_WITHDRAWN" -> "draft_withdraw";
      default -> "other";
    };
  }

  /**
   * Compute property-key diff between two schema JsonNodes. Both nullable —
   * NULL before = creation, NULL after = (deletion, не используется сейчас).
   *
   * <p>Only top-level properties are diffed — глубокая иерархия (nested
   * schemas, allOf etc) сейчас treated как opaque "changed". Достаточно
   * для UI ("+altitude / ~operator" summary).
   */
  static DiffSummary computeDiff(JsonNode before, JsonNode after) {
    Set<String> beforeKeys = topLevelPropertyKeys(before);
    Set<String> afterKeys = topLevelPropertyKeys(after);

    List<String> added = new ArrayList<>();
    List<String> removed = new ArrayList<>();
    List<String> changed = new ArrayList<>();

    Set<String> all = new TreeSet<>();
    all.addAll(beforeKeys);
    all.addAll(afterKeys);

    JsonNode beforeProps = before == null ? null : before.get("properties");
    JsonNode afterProps = after == null ? null : after.get("properties");

    for (String k : all) {
      boolean inBefore = beforeKeys.contains(k);
      boolean inAfter = afterKeys.contains(k);
      if (inBefore && !inAfter) {
        removed.add(k);
      } else if (!inBefore && inAfter) {
        added.add(k);
      } else {
        JsonNode b = beforeProps == null ? null : beforeProps.get(k);
        JsonNode a = afterProps == null ? null : afterProps.get(k);
        if (b != null && a != null && !b.equals(a)) {
          changed.add(k);
        }
      }
    }
    return new DiffSummary(added, removed, changed);
  }

  private static Set<String> topLevelPropertyKeys(JsonNode schema) {
    if (schema == null || !schema.isObject()) return Set.of();
    JsonNode properties = schema.get("properties");
    if (properties == null || !properties.isObject()) return Set.of();
    Set<String> keys = new LinkedHashSet<>();
    Iterator<Map.Entry<String, JsonNode>> it = properties.fields();
    while (it.hasNext()) keys.add(it.next().getKey());
    return keys;
  }

  // === DTOs ===

  /**
   * Per-event timeline entry. {@code version} non-null только для
   * versioning events ({@code definition_create} / {@code definition_update} /
   * {@code draft_publish}); promotional draft events возвращаются с version=null.
   *
   * <p>{@code kind} — frontend-friendly event type label (см.
   * {@link ChangelogService#mapEventTypeToKind}). Используется UI для
   * выбора icon/color/label.
   *
   * <p>{@code id} — audit_log row id. Используется фронтом для deep-link
   * на diff endpoint ({@code GET /changelog/{id}/diff}).
   */
  public record ChangelogEntry(
      Long id,
      String version,
      OffsetDateTime publishedAt,
      Publisher publishedBy,
      String headers,
      DiffSummary diff,
      int recordsAffected,
      boolean isCurrent,
      String kind) {}

  /**
   * Full diff payload для одного changelog event'а — фронт делает deep
   * sidd-by-side render. {@code before} = state ДО event'а, {@code after} =
   * state ПОСЛЕ. Для {@code definition_create} before=null. Для
   * {@code draft_create/review/decided/withdrawn} after=proposed_schema (live
   * не менялся), before=live schema на момент event'а — позволяет UI
   * показать «что предлагалось».
   */
  public record ChangelogDiff(
      Long id,
      String dictionaryName,
      String eventType,
      String kind,
      OffsetDateTime occurredAt,
      Publisher publishedBy,
      JsonNode before,
      JsonNode after,
      DiffSummary summary) {}

  /** Author identity. sub — JWT subject claim (Keycloak user id). */
  public record Publisher(String sub, String name) {}

  /** Property-level change summary. */
  public record DiffSummary(
      List<String> added,
      List<String> removed,
      List<String> changed) {}

  /** Top-level response. */
  public record ChangelogResponse(
      String dictionary,
      String currentVersion,
      List<ChangelogEntry> entries) {}
}
