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

import cloud.nstart.terravault.ordinis.auth.ScopeContext;
import cloud.nstart.terravault.ordinis.domain.definition.DictionaryDefinition;
import cloud.nstart.terravault.ordinis.domain.draft.DraftOperation;
import cloud.nstart.terravault.ordinis.domain.draft.DraftStatus;
import cloud.nstart.terravault.ordinis.domain.draft.RecordDraft;
import cloud.nstart.terravault.ordinis.domain.draft.RecordDraftRepository;
import cloud.nstart.terravault.ordinis.restapi.dto.CreateRecordRequest;
import cloud.nstart.terravault.ordinis.restapi.dto.draft.SubmitDraftRequest;
import cloud.nstart.terravault.ordinis.restapi.error.OrdinisException;
import cloud.nstart.terravault.ordinis.restapi.service.DictionaryDefinitionService;
import cloud.nstart.terravault.ordinis.restapi.service.DictionaryRecordService;
import io.micrometer.core.instrument.Gauge;
import io.micrometer.core.instrument.MeterRegistry;
import io.micrometer.core.instrument.Tags;
import io.micrometer.core.instrument.Timer;
import jakarta.annotation.PostConstruct;
import org.locationtech.jts.geom.Geometry;
import org.locationtech.jts.io.ParseException;
import org.locationtech.jts.io.WKTReader;
import org.locationtech.jts.io.WKTWriter;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Isolation;
import org.springframework.transaction.annotation.Transactional;

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

/**
 * Approval workflow v2 — Phase 0 skeleton.
 *
 * <p>Текущий scope: submit + list + get (read-only operations + new draft
 * insert). Полный lifecycle (approve / reject / withdraw + COPY-on-approve)
 * — Phase 1.
 *
 * <p>Decisions (auto, per design doc):
 * <ul>
 *   <li>D1=A: per-dictionary policy через колонки на dictionary_definitions.</li>
 *   <li>D2=A: existing endpoints вернут 409 если approval_required=true
 *       (handled в DictionaryRecordService — TBD Phase 1).</li>
 *   <li>D3=A: pool model для reviewer'ов (нет explicit assignment).</li>
 *   <li>D4=A: rejected drafts kept (status=rejected, kept в таблице).</li>
 * </ul>
 */
@Service
public class DraftService {

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

  private final RecordDraftRepository draftRepo;
  private final DictionaryDefinitionService definitionService;
  private final DictionaryRecordService recordService;
  private final ScopeContext scopeContext;
  /** Phase 4 monitoring. Optional — null в test ctor'ах (no metrics in unit tests). */
  private final Optional<MeterRegistry> meterRegistry;
  /** Phase 1d: realm role gate. Null в test ctor → skip enforcement. */
  private final cloud.nstart.terravault.ordinis.restapi.auth.WorkflowRoles workflowRoles;
  /** Audit + outbox for record-draft lifecycle. Null in legacy test ctors —
   *  helpers below null-check. Closes 2026-05-14 audit gap: record-draft
   *  decisions used to leave no trail beyond downstream RECORD_* events. */
  private final cloud.nstart.terravault.ordinis.restapi.audit.AuditLogger auditLogger;
  private final cloud.nstart.terravault.ordinis.outbox.OutboxRecorder outboxRecorder;

  @org.springframework.beans.factory.annotation.Autowired
  public DraftService(
      RecordDraftRepository draftRepo,
      DictionaryDefinitionService definitionService,
      DictionaryRecordService recordService,
      ScopeContext scopeContext,
      Optional<MeterRegistry> meterRegistry,
      cloud.nstart.terravault.ordinis.restapi.auth.WorkflowRoles workflowRoles,
      cloud.nstart.terravault.ordinis.restapi.audit.AuditLogger auditLogger,
      cloud.nstart.terravault.ordinis.outbox.OutboxRecorder outboxRecorder) {
    this.draftRepo = draftRepo;
    this.definitionService = definitionService;
    this.recordService = recordService;
    this.scopeContext = scopeContext;
    this.meterRegistry = meterRegistry;
    this.workflowRoles = workflowRoles;
    this.auditLogger = auditLogger;
    this.outboxRecorder = outboxRecorder;
  }

  /** Test-friendly ctor — без recordService + без metrics для unit tests submit/list flow. */
  public DraftService(
      RecordDraftRepository draftRepo,
      DictionaryDefinitionService definitionService,
      ScopeContext scopeContext) {
    this(draftRepo, definitionService, null, scopeContext, Optional.empty(), null, null, null);
  }

  /** Test-friendly ctor — с recordService но без metrics. Phase 1 + Phase 4 ctor compat. */
  public DraftService(
      RecordDraftRepository draftRepo,
      DictionaryDefinitionService definitionService,
      DictionaryRecordService recordService,
      ScopeContext scopeContext) {
    this(draftRepo, definitionService, recordService, scopeContext, Optional.empty(), null, null, null);
  }

  /** Phase 1d guard: enforce realm role если WorkflowRoles bean wired. */
  private void requireRoleIfEnforced(String role) {
    if (workflowRoles != null) {
      workflowRoles.requireRole(role);
    }
  }

  /**
   * Phase 2 (per-dictionary fine-grained role gate): если у словаря задан
   * {@code approval_min_role}, дополнительно требуем чтобы reviewer имел
   * именно эту role (поверх стандартного {@code ordinis:record:reviewer}).
   *
   * <p>Use case: критичный справочник помечен как требующий повышенного
   * доступа — например, {@code approvalMinRole = "ordinis:restricted"},
   * тогда approve может только user с этой role'ю даже если у него есть
   * record:reviewer на других dict'ах. Pure UI gate был до Phase 2 —
   * чистый no-op, теперь enforced.
   *
   * <p>Admin super-role {@code ordinis:admin} проходит автоматически
   * (через {@link WorkflowRoles#hasRole}).
   */
  private void requireDictMinRoleIfSet(DictionaryDefinition def) {
    if (workflowRoles == null) return;
    String minRole = def.getApprovalMinRole();
    if (minRole == null || minRole.isBlank()) return;
    workflowRoles.requireRole(minRole);
  }

  /**
   * Регистрирует Gauge для pending queue depth. Polled на scrape — Micrometer
   * вызывает {@link RecordDraftRepository#countPending()} раз в Prometheus tick.
   * Phase 4 monitoring: queue depth → soak observability + alerting.
   */
  @PostConstruct
  void registerGauges() {
    meterRegistry.ifPresent(reg ->
        Gauge.builder("nsi_draft_pending_total", draftRepo, RecordDraftRepository::countPending)
            .description("Approval workflow: pending drafts ожидающих review (D3 pool model)")
            .register(reg));
  }

  /**
   * Submit новой draft. Maker = current authenticated user.
   *
   * <p>Pre-conditions:
   * <ul>
   *   <li>Dict существует и виден caller'у в scope.</li>
   *   <li>Нет другого pending draft на этот business_key (DB exclusion
   *       constraint обеспечивает; конфликт = 409).</li>
   * </ul>
   *
   * <p>Validation схемы — Phase 1 (нужен RecordValidator + ReferenceValidator
   * inject + apply on submit). Для skeleton принимаем data как-есть.
   */
  @Transactional
  public RecordDraft submit(String dictionaryName, SubmitDraftRequest req) {
    DictionaryDefinition def = definitionService.findByName(dictionaryName);
    String makerId = currentUserId();

    var draft = new RecordDraft(
        UUID.randomUUID(),
        def.getId(),
        req.businessKey(),
        req.operation(),
        makerId);
    draft.setData(req.data());
    draft.setGeometry(parseWkt(req.geometryWkt()));
    draft.setProposedValidFrom(req.validFrom());
    draft.setProposedValidTo(req.validTo());
    draft.setMakerComment(req.comment());

    try {
      RecordDraft saved = draftRepo.saveAndFlush(draft);
      log.info("Draft submitted: dict={} bk={} op={} maker={}",
          dictionaryName, req.businessKey(), req.operation(), makerId);
      countSubmit(dictionaryName, req.operation(), "ok");
      emitDraftEvent("RecordDraftSubmitted", "DRAFT_SUBMIT", def, saved, null);
      return saved;
    } catch (DataIntegrityViolationException ex) {
      // SQLSTATE 23P01 = exclusion_violation (record_drafts_one_pending_per_key).
      // Все остальные DataIntegrity (check, FK, NOT NULL) пробрасываем как-есть —
      // иначе скрываем баги схемы (Phase 3 lessons learned: enum case mismatch
      // прятался в маске draft_already_pending).
      if (isExclusionViolation(ex)) {
        countSubmit(dictionaryName, req.operation(), "already_pending");
        throw OrdinisException.conflict(
            "draft_already_pending",
            "На запись business_key=" + req.businessKey() + " уже есть pending draft.");
      }
      countSubmit(dictionaryName, req.operation(), "error");
      throw ex;
    }
  }

  /** Distinguish exclusion-constraint violation (23P01) from other DataIntegrity. */
  private static boolean isExclusionViolation(DataIntegrityViolationException ex) {
    Throwable t = ex;
    while (t != null) {
      if (t instanceof java.sql.SQLException sql && "23P01".equals(sql.getSQLState())) {
        return true;
      }
      t = t.getCause();
    }
    return false;
  }

  @Transactional(readOnly = true)
  public RecordDraft findById(UUID id) {
    return draftRepo.findById(id)
        .orElseThrow(() -> OrdinisException.notFound(
            "draft_not_found", "Draft not found: " + id));
  }

  @Transactional(readOnly = true)
  public List<RecordDraft> listPendingByDictionary(String dictionaryName) {
    DictionaryDefinition def = definitionService.findByName(dictionaryName);
    return draftRepo.findPendingByDictionary(def.getId());
  }

  @Transactional(readOnly = true)
  public Page<RecordDraft> listPendingGlobal(Pageable pageable) {
    return draftRepo.findPending(pageable);
  }

  // === Phase 1: full lifecycle ===

  /**
   * Approve pending draft → COPY → live. checker'ом выступает текущий
   * authenticated user. SERIALIZABLE tx: validate live constraints +
   * apply via {@link DictionaryRecordService#applyApprovedCreate} и т.д.
   * + outbox event + audit. Draft marked status=APPROVED (kept для history).
   *
   * <p>Errors:
   * <ul>
   *   <li>404 draft_not_found — несуществующий id</li>
   *   <li>409 draft_not_pending — draft уже approved/rejected/withdrawn</li>
   *   <li>409 self_approve_forbidden — reviewer == maker</li>
   *   <li>пробрасывает любые ошибки валидации schema (validation_failed),
   *       constraint violation (record_period_overlap и т.д.) — fixable
   *       admin'ом через update draft (Phase 1.5) или re-submit.</li>
   * </ul>
   */
  @Transactional(isolation = Isolation.SERIALIZABLE)
  public RecordDraft approve(UUID draftId, String reviewComment) {
    if (recordService == null) {
      throw new IllegalStateException("DictionaryRecordService not wired (test ctor used).");
    }
    // Phase 1d: realm role gate (record-specific 'ordinis:record:reviewer').
    requireRoleIfEnforced(cloud.nstart.terravault.ordinis.restapi.auth.WorkflowRoles.RECORD_REVIEWER);
    String reviewerId = currentUserId();
    RecordDraft draft = findById(draftId);
    if (draft.getStatus() != DraftStatus.PENDING) {
      throw OrdinisException.conflict(
          "draft_not_pending",
          "Draft " + draftId + " status=" + draft.getStatus()
              + " — approve possible только PENDING.");
    }
    if (draft.getMakerId() != null && draft.getMakerId().equals(reviewerId)) {
      throw OrdinisException.conflict(
          "self_approve_forbidden",
          "Maker '" + reviewerId + "' не может approve свой draft (D3 maker-checker).");
    }
    DictionaryDefinition def = definitionService.findById(draft.getDictionaryId());
    // Phase 2: dict-level минимальная роль ревьюера (e.g. "ordinis:restricted").
    requireDictMinRoleIfSet(def);

    // Apply через recordService — validate-on-approve + outbox + audit + COPY → live.
    var req = toCreateRecordRequest(draft);
    switch (draft.getOperation()) {
      case CREATE -> recordService.applyApprovedCreate(def.getName(), req);
      case UPDATE -> recordService.applyApprovedUpdate(def.getName(), draft.getBusinessKey(), req);
      case CLOSE -> recordService.applyApprovedClose(
          def.getName(), draft.getBusinessKey(),
          draft.getProposedValidFrom(), reviewComment);
    }

    // Mark approved (kept в таблице — D4 audit trail). Delete не делаем —
    // draft.id остаётся ссылочно валидным (audit log → draft_id).
    OffsetDateTime now = OffsetDateTime.now();
    draft.setStatus(DraftStatus.APPROVED);
    draft.setReviewerId(reviewerId);
    draft.setReviewedAt(now);
    draft.setReviewComment(reviewComment);
    RecordDraft saved = draftRepo.saveAndFlush(draft);

    recordDecision(def.getName(), draft.getOperation(), "approved", draft.getSubmittedAt(), now);
    log.info("Draft approved: id={} dict={} bk={} op={} reviewer={}",
        draft.getId(), def.getName(), draft.getBusinessKey(),
        draft.getOperation(), reviewerId);
    emitDraftEvent("RecordDraftApproved", "DRAFT_APPROVE", def, saved,
        java.util.Map.of("reviewedAt", now.toString()));
    return saved;
  }

  /**
   * Reject pending draft (D4=A: status=rejected, kept в таблице).
   * Reviewer должен указать reason — для compliance + maker видит почему.
   */
  @Transactional
  public RecordDraft reject(UUID draftId, String reviewComment) {
    // Phase 1d: same role как approve — reviewer'ы могут оба действия.
    requireRoleIfEnforced(cloud.nstart.terravault.ordinis.restapi.auth.WorkflowRoles.RECORD_REVIEWER);
    if (reviewComment == null || reviewComment.isBlank()) {
      throw OrdinisException.badRequest(
          "reject_reason_required",
          "Укажите причину отклонения (review_comment) — compliance.");
    }
    String reviewerId = currentUserId();
    RecordDraft draft = findById(draftId);
    if (draft.getStatus() != DraftStatus.PENDING) {
      throw OrdinisException.conflict(
          "draft_not_pending",
          "Draft " + draftId + " status=" + draft.getStatus()
              + " — reject possible только PENDING.");
    }
    if (draft.getMakerId() != null && draft.getMakerId().equals(reviewerId)) {
      throw OrdinisException.conflict(
          "self_reject_forbidden",
          "Maker '" + reviewerId + "' не может reject свой draft.");
    }
    // Phase 2: dict-level минимальная роль ревьюера (e.g. "ordinis:restricted").
    DictionaryDefinition def = definitionService.findById(draft.getDictionaryId());
    requireDictMinRoleIfSet(def);

    OffsetDateTime now = OffsetDateTime.now();
    draft.setStatus(DraftStatus.REJECTED);
    draft.setReviewerId(reviewerId);
    draft.setReviewedAt(now);
    draft.setReviewComment(reviewComment);
    RecordDraft saved = draftRepo.saveAndFlush(draft);
    // def уже resolved выше для min-role check — reuse.
    recordDecision(def.getName(), draft.getOperation(), "rejected", draft.getSubmittedAt(), now);
    log.info("Draft rejected: id={} reviewer={} reason='{}'",
        draftId, reviewerId, reviewComment);
    emitDraftEvent("RecordDraftRejected", "DRAFT_REJECT", def, saved,
        java.util.Map.of("reviewedAt", now.toString()));
    return saved;
  }

  /**
   * Maker отзывает свой pending draft (передумал submit'ить).
   * status=WITHDRAWN, kept (audit trail). Только maker может withdraw —
   * reviewer'ам недоступно.
   */
  @Transactional
  public RecordDraft withdraw(UUID draftId) {
    String userId = currentUserId();
    RecordDraft draft = findById(draftId);
    if (draft.getStatus() != DraftStatus.PENDING) {
      throw OrdinisException.conflict(
          "draft_not_pending",
          "Draft " + draftId + " status=" + draft.getStatus()
              + " — withdraw possible только PENDING.");
    }
    if (!userId.equals(draft.getMakerId())) {
      throw OrdinisException.forbidden(
          "not_draft_maker",
          "Только maker draft'а может withdraw. Текущий: " + userId
              + ", maker: " + draft.getMakerId());
    }

    OffsetDateTime now = OffsetDateTime.now();
    draft.setStatus(DraftStatus.WITHDRAWN);
    draft.setReviewedAt(now);
    RecordDraft saved = draftRepo.saveAndFlush(draft);
    DictionaryDefinition def = definitionService.findById(draft.getDictionaryId());
    recordDecision(def.getName(), draft.getOperation(), "withdrawn", draft.getSubmittedAt(), now);
    log.info("Draft withdrawn by maker: id={} maker={}", draftId, userId);
    emitDraftEvent("RecordDraftWithdrawn", "DRAFT_WITHDRAW", def, saved, null);
    return saved;
  }

  // === Phase 4 metrics helpers ===

  /**
   * Counter: nsi_draft_submit_total{dictionary, operation, outcome}.
   * Outcome: ok / already_pending / error. Помогает алертить аномальный
   * процент already_pending (concurrent edits) или error (schema bugs).
   */
  private void countSubmit(String dictName, DraftOperation op, String outcome) {
    meterRegistry.ifPresent(reg ->
        reg.counter("nsi_draft_submit_total",
            Tags.of("dictionary", dictName, "operation", op.name(), "outcome", outcome))
            .increment());
  }

  /**
   * Counter: nsi_draft_decision_total{dictionary, operation, outcome=approved|rejected|withdrawn}.
   * Timer: nsi_draft_review_duration_seconds — distribution time-to-decide.
   * Outcome=withdrawn ≠ review (maker сам отозвал), но включаем — нужно для
   * full lifecycle visibility и сравнения throughput.
   */
  private void recordDecision(
      String dictName, DraftOperation op, String outcome,
      OffsetDateTime submittedAt, OffsetDateTime decidedAt) {
    meterRegistry.ifPresent(reg -> {
      Tags tags = Tags.of("dictionary", dictName, "operation", op.name(), "outcome", outcome);
      reg.counter("nsi_draft_decision_total", tags).increment();
      if (submittedAt != null && decidedAt != null) {
        Duration d = Duration.between(submittedAt.toInstant(), decidedAt.toInstant());
        if (!d.isNegative()) {
          Timer t = Timer.builder("nsi_draft_review_duration_seconds")
              .description("Approval workflow: время от submit до decision")
              .tags(tags)
              .publishPercentileHistogram()
              .register(reg);
          t.record(d);
        }
      }
    });
  }

  /** Drafts автора: tracking "что я отправил" в admin UI. */
  @Transactional(readOnly = true)
  public Page<RecordDraft> listByMaker(String makerId, Pageable pageable) {
    return draftRepo.findByMaker(makerId, pageable);
  }

  /** Convert draft payload → CreateRecordRequest for {@link DictionaryRecordService}. */
  private static CreateRecordRequest toCreateRecordRequest(RecordDraft draft) {
    String wkt = draft.getGeometry() == null
        ? null
        : new WKTWriter().write(draft.getGeometry());
    return new CreateRecordRequest(
        draft.getBusinessKey(),
        draft.getData(),
        wkt,
        draft.getProposedValidFrom(),
        draft.getProposedValidTo());
  }

  // === Internals ===

  /** Resolve current user from JWT subject. Если нет auth — fallback "anonymous". */
  private String currentUserId() {
    var auth = org.springframework.security.core.context.SecurityContextHolder.getContext()
        .getAuthentication();
    if (auth == null || !auth.isAuthenticated()) return "anonymous";
    return auth.getName();
  }

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

  /** ScopeContext kept for future scope-filter Phase 1 (когда draft list
   *  может ограничиваться по scope). Suppress unused warning сейчас. */
  @SuppressWarnings("unused")
  private ScopeContext scopeContext() { return scopeContext; }

  /** Convenience overload: всё что нужно от datetime. */
  static OffsetDateTime nowUtc() { return OffsetDateTime.now(); }

  /**
   * Emit one record-draft lifecycle event to BOTH audit_log + outbox. No-op
   * when either dependency is null (legacy test ctors). Failures swallowed
   * inside AuditLogger / OutboxRecorder so a sink hiccup never breaks the
   * user-facing transaction.
   *
   * <p>Outbox payload is a flat {@code Map} (matches SchemaDraftService
   * pattern — flexible JSON, no per-event class needed). Webhook subscribers
   * + notifications service can subscribe to {@code RecordDraftSubmitted /
   * Approved / Rejected / Withdrawn} now that they actually fire.
   */
  private void emitDraftEvent(
      String eventType,
      String action,
      DictionaryDefinition def,
      RecordDraft draft,
      java.util.Map<String, Object> extraPayload) {
    if (auditLogger != null) {
      auditLogger.recordDraftEvent(
          eventType, action, def,
          draft.getBusinessKey(),
          /* before */ null, /* after */ draft.getData());
    }
    if (outboxRecorder != null) {
      java.util.Map<String, Object> payload = new java.util.HashMap<>();
      payload.put("draftId", draft.getId().toString());
      payload.put("dictionaryName", def.getName());
      payload.put("businessKey", draft.getBusinessKey());
      payload.put("operation", draft.getOperation().name());
      payload.put("status", draft.getStatus().name());
      payload.put("makerId", draft.getMakerId());
      if (draft.getReviewerId() != null) payload.put("reviewerId", draft.getReviewerId());
      if (draft.getReviewComment() != null) payload.put("reviewComment", draft.getReviewComment());
      if (extraPayload != null) payload.putAll(extraPayload);
      try {
        outboxRecorder.record(
            eventType,
            "RecordDraft",
            draft.getId().toString(),
            def.getName(),
            def.getScope(),
            def.getBundle(),
            "record-draft:" + draft.getId(),
            payload);
      } catch (RuntimeException ex) {
        log.warn("emitDraftEvent outbox failed: type={} draftId={} dict={}",
            eventType, draft.getId(), def.getName(), ex);
      }
    }
  }
}
